Microsoft To Do checklist support

pull/3216/head
Alex Baker 11 months ago
parent 74c69a0799
commit 53209226a0

@ -23,6 +23,7 @@ import org.tasks.notifications.NotificationManager
import org.tasks.preferences.Preferences import org.tasks.preferences.Preferences
import org.tasks.preferences.QueryPreferences import org.tasks.preferences.QueryPreferences
import org.tasks.sync.SyncAdapters import org.tasks.sync.SyncAdapters
import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
class TaskDao @Inject constructor( 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(task: Task) = save(task, fetch(task.id))
suspend fun save(tasks: List<Task>, originals: List<Task>) { suspend fun save(tasks: List<Task>, originals: List<Task>) {
Timber.d("Saving $tasks")
taskDao.updateInternal(tasks) taskDao.updateInternal(tasks)
tasks.forEach { task -> afterUpdate(task, originals.find { it.id == task.id }) } tasks.forEach { task -> afterUpdate(task, originals.find { it.id == task.id }) }
} }
suspend fun save(task: Task, original: Task?) { suspend fun save(task: Task, original: Task?) {
if (taskDao.update(task, original)) { if (taskDao.update(task, original)) {
Timber.d("Saved $task")
afterUpdate(task, original) afterUpdate(task, original)
workManager.triggerNotifications() workManager.triggerNotifications()
workManager.scheduleRefresh() workManager.scheduleRefresh()

@ -1,12 +1,12 @@
package org.tasks.sync.microsoft package org.tasks.sync.microsoft
import org.tasks.data.entity.Task
import net.fortuna.ical4j.model.Recur import net.fortuna.ical4j.model.Recur
import net.fortuna.ical4j.model.WeekDay import net.fortuna.ical4j.model.WeekDay
import net.fortuna.ical4j.model.WeekDayList import net.fortuna.ical4j.model.WeekDayList
import org.tasks.data.createDueDate import org.tasks.data.createDueDate
import org.tasks.data.entity.CaldavTask import org.tasks.data.entity.CaldavTask
import org.tasks.data.entity.TagData import org.tasks.data.entity.TagData
import org.tasks.data.entity.Task
import org.tasks.date.DateTimeUtils import org.tasks.date.DateTimeUtils
import org.tasks.sync.microsoft.Tasks.Task.RecurrenceDayOfWeek import org.tasks.sync.microsoft.Tasks.Task.RecurrenceDayOfWeek
import org.tasks.sync.microsoft.Tasks.Task.RecurrenceType 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_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'" 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( fun Task.applyRemote(
remote: Tasks.Task, remote: Tasks.Task,
defaultPriority: Int, defaultPriority: Int,
@ -84,7 +100,10 @@ object MicrosoftConverter {
// sync files // sync files
} }
fun Task.toRemote(caldavTask: CaldavTask, tags: List<TagData>): Tasks.Task { fun Task.toRemote(
caldavTask: CaldavTask,
tags: List<TagData>,
): Tasks.Task {
return Tasks.Task( return Tasks.Task(
id = caldavTask.remoteId, id = caldavTask.remoteId,
title = title, title = title,
@ -168,14 +187,26 @@ object MicrosoftConverter {
} }
} else { } else {
null null
} },
// subtasks to checklist
// isReminderOn = // isReminderOn =
// reminders // reminders
// files // 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 = private fun String?.parseDateTime(): Long =
this this
?.let { ZonedDateTime.parse(this).toInstant().toEpochMilli() } ?.let { ZonedDateTime.parse(this).toInstant().toEpochMilli() }

@ -59,4 +59,23 @@ class MicrosoftService(
suspend fun deleteTask(listId: String, taskId: String) = suspend fun deleteTask(listId: String, taskId: String) =
client.delete("$baseUrl/lists/$listId/tasks/$taskId") 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")
} }

@ -31,9 +31,12 @@ import org.tasks.data.entity.CaldavTask
import org.tasks.data.entity.TagData import org.tasks.data.entity.TagData
import org.tasks.data.entity.Task import org.tasks.data.entity.Task
import org.tasks.http.HttpClientFactory import org.tasks.http.HttpClientFactory
import org.tasks.http.NotFoundException
import org.tasks.preferences.Preferences import org.tasks.preferences.Preferences
import org.tasks.sync.microsoft.Error.Companion.toMicrosoftError import org.tasks.sync.microsoft.Error.Companion.toMicrosoftError
import org.tasks.sync.microsoft.MicrosoftConverter.applyRemote 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 org.tasks.sync.microsoft.MicrosoftConverter.toRemote
import timber.log.Timber import timber.log.Timber
import java.io.IOException import java.io.IOException
@ -151,12 +154,8 @@ class MicrosoftSynchronizer @Inject constructor(
for (task in caldavDao.getMoved(local.uuid!!)) { for (task in caldavDao.getMoved(local.uuid!!)) {
deleteRemoteResource(microsoft, local, task) deleteRemoteResource(microsoft, local, task)
} }
for (task in taskDao.getCaldavTasksToPush(local.uuid!!)) { for (task in taskDao.getCaldavTasksToPush(local.uuid!!).sortedBy { it.parent }) {
try {
pushTask(local, task, microsoft) pushTask(local, task, microsoft)
} catch (e: IOException) {
Timber.e(e)
}
} }
} }
@ -166,13 +165,40 @@ class MicrosoftSynchronizer @Inject constructor(
task: CaldavTask, task: CaldavTask,
): Boolean { ): Boolean {
val listId = list.uuid val listId = list.uuid
val parentId = task.remoteParent
val taskId = task.remoteId val taskId = task.remoteId
val success = when { val success = when {
task.lastSync == 0L -> true task.lastSync == 0L -> true
listId.isNullOrBlank() -> false listId.isNullOrBlank() -> false
taskId.isNullOrBlank() -> false taskId.isNullOrBlank() -> false
else -> { parentId.isNullOrBlank() -> {
try {
microsoft.deleteTask(listId, taskId) 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 -> {
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 true
} }
} }
@ -190,14 +216,19 @@ class MicrosoftSynchronizer @Inject constructor(
) { ) {
val caldavTask = caldavDao.getTask(task.id) ?: return val caldavTask = caldavDao.getTask(task.id) ?: return
if (task.isDeleted) { if (task.isDeleted) {
Timber.d("Deleting $task")
if (deleteRemoteResource(microsoft, list, caldavTask)) { if (deleteRemoteResource(microsoft, list, caldavTask)) {
taskDeleter.delete(taskDao.getChildren(task.id) + task.id) taskDeleter.delete(taskDao.getChildren(task.id) + task.id)
} }
return return
} }
val remoteTask = task.toRemote(caldavTask, tagDataDao.getTagDataForTask(task.id)) val isNew = caldavTask.lastSync == 0L
try { if (task.parent == 0L) {
val result: Tasks.Task = if (caldavTask.lastSync == 0L) { val remoteTask = task.toRemote(
caldavTask = caldavTask,
tags = tagDataDao.getTagDataForTask(task.id),
)
val result: Tasks.Task = if (isNew) {
Timber.d("Uploading new task: $task") Timber.d("Uploading new task: $task")
microsoft.createTask(list.uuid!!, remoteTask) microsoft.createTask(list.uuid!!, remoteTask)
} else { } else {
@ -208,18 +239,24 @@ class MicrosoftSynchronizer @Inject constructor(
caldavTask.obj = "${result.id}.json" caldavTask.obj = "${result.id}.json"
caldavTask.lastSync = task.modificationDate caldavTask.lastSync = task.modificationDate
vtodoCache.putVtodo(list, caldavTask, json.encodeToString(result)) 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 { } else {
Timber.e(e) 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.d("Updating existing checklist item: $task")
microsoft.updateChecklistItem(list.uuid!!, caldavParent, remoteTask)
} }
} catch (e: Exception) { caldavTask.remoteId = result.id
Timber.e(e) 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( private suspend fun deltaSync(
@ -235,8 +272,9 @@ class MicrosoftSynchronizer @Inject constructor(
updateTask(list, remote) updateTask(list, remote)
} else { } else {
val caldavTasks = caldavDao.getTasksByRemoteId(list.uuid!!, listOf(remote.id!!)) 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) vtodoCache.delete(list, caldavTasks)
val taskIds = caldavTasks.map { it.id }.flatMap { taskDao.getChildren(it) + it }
taskDeleter.delete(taskIds) taskDeleter.delete(taskIds)
} }
} }
@ -354,21 +392,78 @@ class MicrosoftSynchronizer @Inject constructor(
obj = "${remote.id}.json" obj = "${remote.id}.json"
) )
val dirty = existing != null && task.modificationDate > existing.lastSync 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) task.applyRemote(remote, preferences.defaultPriority)
val existingSubtasks: List<CaldavTask> = 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.suppressSync()
task.suppressRefresh() task.suppressRefresh()
taskDao.save(task) taskDao.save(task)
vtodoCache.putVtodo(list, caldavTask, json.encodeToString(remote)) vtodoCache.putVtodo(list, caldavTask, json.encodeToString(remote))
tagDao.applyTags(task, tagDataDao, getTags(remote.categories ?: emptyList())) tagDao.applyTags(task, tagDataDao, getTags(remote.categories ?: emptyList()))
remote.checklistItems?.forEach { remote.checklistItems?.let {
// TODO: handle checklist items syncChecklist(
list = list,
parentId = task.id,
parentRemoteId = caldavTask.remoteId!!,
checklistItems = it,
)
} }
caldavTask.etag = remote.etag caldavTask.etag = remote.etag
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 syncChecklist(
list: CaldavCalendar,
parentId: Long,
parentRemoteId: String,
checklistItems: List<Tasks.Task.ChecklistItem>,
) {
val existingSubtasks: List<CaldavTask> = 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) { if (!dirty) {
caldavTask.lastSync = task.modificationDate caldavTask.lastSync = task.modificationDate
} }
@ -380,6 +475,7 @@ class MicrosoftSynchronizer @Inject constructor(
Timber.d("UPDATE $caldavTask") Timber.d("UPDATE $caldavTask")
} }
} }
}
private suspend fun getTags(categories: List<String>): List<TagData> { private suspend fun getTags(categories: List<String>): List<TagData> {
if (categories.isEmpty()) { if (categories.isEmpty()) {

@ -1,5 +1,7 @@
package org.tasks.sync.microsoft package org.tasks.sync.microsoft
import kotlinx.serialization.EncodeDefault
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import org.tasks.data.Redacted import org.tasks.data.Redacted
@ -11,13 +13,13 @@ data class Tasks(
@SerialName("@odata.deltaLink") val nextDelta: String? = null, @SerialName("@odata.deltaLink") val nextDelta: String? = null,
) { ) {
@Serializable @Serializable
data class Task( data class Task @OptIn(ExperimentalSerializationApi::class) constructor(
@SerialName("@odata.etag") val etag: String? = null, @SerialName("@odata.etag") val etag: String? = null,
val id: String? = null, val id: String? = null,
@Redacted val title: String? = null, @Redacted val title: String? = null,
val body: Body? = null, val body: Body? = null,
val importance: Importance = Importance.low, @EncodeDefault val importance: Importance = Importance.low,
val status: Status = Status.notStarted, @EncodeDefault val status: Status = Status.notStarted,
val categories: List<String>? = null, val categories: List<String>? = null,
val isReminderOn: Boolean = false, val isReminderOn: Boolean = false,
val createdDateTime: String? = null, val createdDateTime: String? = null,
@ -100,9 +102,9 @@ data class Tasks(
@Serializable @Serializable
data class ChecklistItem( data class ChecklistItem(
val id: String, val id: String? = null,
val displayName: String, val displayName: String,
val createdDateTime: String, val createdDateTime: String? = null,
val isChecked: Boolean, val isChecked: Boolean,
val checkedDateTime: String? = null, val checkedDateTime: String? = null,
) )

@ -88,7 +88,8 @@ FROM (
WHERE caldav_tasks.cd_calendar = :calendar WHERE caldav_tasks.cd_calendar = :calendar
AND cd_deleted = 0 AND cd_deleted = 0
AND (tasks.modified > caldav_tasks.cd_last_sync OR caldav_tasks.cd_last_sync = 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<Task> abstract suspend fun getCaldavTasksToPush(calendar: String): List<Task>
// --- SQL clause generators // --- SQL clause generators

Loading…
Cancel
Save