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.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<Task>, originals: List<Task>) {
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()

@ -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<TagData>): Tasks.Task {
fun Task.toRemote(
caldavTask: CaldavTask,
tags: List<TagData>,
): 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() }

@ -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")
}

@ -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<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.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<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) {
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<String>): List<TagData> {
if (categories.isEmpty()) {
return emptyList()

@ -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<String>? = 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,
)

@ -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<Task>
// --- SQL clause generators

Loading…
Cancel
Save