Skip to content

Commit 2605867

Browse files
committed
Support Todoist to create and update tasks
1 parent 405d0c0 commit 2605867

File tree

15 files changed

+256
-6
lines changed

15 files changed

+256
-6
lines changed

.gitignore

+2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
.gradle
2+
.kotlin/
3+
.shelf/
24
build/
35
!gradle/wrapper/gradle-wrapper.jar
46
!**/src/main/**/build/

api/src/main/kotlin/me/snoty/backend/database/mongo/BsonUtils.kt

+2-2
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ fun <T : Any> CodecRegistry.encode(value: T): Document {
2020
val codec = get(value::class.java) as Codec<T>
2121
codec.encode(writer, value, EncoderContext.builder().build())
2222

23-
val documentCodec = DocumentCodec()
23+
val documentCodec = DocumentCodec(this)
2424

2525
return documentCodec.decode(BsonDocumentReader(document), DecoderContext.builder().build())
2626
}
@@ -31,7 +31,7 @@ fun <T : Any> CodecRegistry.encode(value: T): Document {
3131
inline fun <T : Any> CodecRegistry.decode(clazz: KClass<T>, document: Document): T {
3232
return get(clazz.java)
3333
.decode(
34-
BsonDocumentReader(document.toBsonDocument()),
34+
BsonDocumentReader(document.toBsonDocument(BsonDocument::class.java, this)),
3535
DecoderContext.builder().build()
3636
)
3737
}

api/src/main/kotlin/me/snoty/backend/utils/StringUtils.kt

+5
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,8 @@ package me.snoty.backend.utils
66
fun String.toTitleCase()
77
= replaceFirstChar { it.uppercaseChar() }
88
.replace(Regex("([a-z])([A-Z])")) { "${it.groupValues[1]} ${it.groupValues[2]}" }
9+
10+
/**
11+
* @return this string if it is not blank, otherwise null
12+
*/
13+
fun String?.orNull() = this?.ifBlank { null }

integrations/api/src/main/kotlin/me/snoty/integration/common/diff/state/UserEntityStatesStatistics.kt

+5-1
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,12 @@ import com.mongodb.kotlin.client.coroutine.AggregateFlow
77
import me.snoty.backend.database.mongo.Aggregations
88
import me.snoty.backend.database.mongo.aggregate
99
import me.snoty.backend.database.mongo.mongoField
10+
import org.bson.types.ObjectId
1011

11-
data class UserEntityStateStats(val totalEntities: Long)
12+
/**
13+
* @param _id will always be null, just there to make the logger stfu
14+
*/
15+
data class UserEntityStateStats(private val _id: ObjectId, val totalEntities: Long)
1216

1317
fun EntityStateCollection.getStatistics(): AggregateFlow<UserEntityStateStats> = aggregate<UserEntityStateStats>(
1418
Aggregates.project(

integrations/api/src/main/kotlin/me/snoty/integration/common/wiring/data/impl/BsonIntermediateDataMapper.kt

+1-1
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ class BsonIntermediateDataMapper(private val codecRegistry: CodecRegistry) : Int
2727

2828
override fun <R : Any> serialize(data: R) = BsonIntermediateData(
2929
when (data) {
30-
is Document -> (data)
30+
is Document -> data
3131
else -> codecRegistry.encode(data)
3232
}
3333
)

integrations/builtin/src/main/kotlin/me/snoty/integration/builtin/diff/injector/DiffInjectorNodeHandler.kt

+7
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@ package me.snoty.integration.builtin.diff.injector
33
import kotlinx.serialization.Serializable
44
import me.snoty.integration.builtin.diff.DiffNodeHandler
55
import me.snoty.integration.common.annotation.RegisterNode
6+
import me.snoty.integration.common.diff.DiffResult
67
import me.snoty.integration.common.diff.EntityStateService
78
import me.snoty.integration.common.model.NodePosition
89
import me.snoty.integration.common.model.metadata.EmptySchema
10+
import me.snoty.integration.common.model.metadata.FieldDefaultValue
911
import me.snoty.integration.common.wiring.Node
1012
import me.snoty.integration.common.wiring.NodeHandleContext
1113
import me.snoty.integration.common.wiring.data.IntermediateData
@@ -21,6 +23,8 @@ import org.koin.core.annotation.Single
2123
data class DiffInjectorSettings(
2224
override val name: String = "Diff Injector",
2325
val excludeFields: List<String>,
26+
@FieldDefaultValue("false")
27+
val emitUnchanged: Boolean = false,
2428
) : NodeSettings
2529

2630
@RegisterNode(
@@ -44,6 +48,9 @@ class DiffInjectorNodeHandler(
4448

4549
val (newData, newStates) = handleStatesAndDiff(logger, node, input, settings.excludeFields)
4650
val items = newData
51+
.filter { (id, _) ->
52+
settings.emitUnchanged || newStates[id]?.diffResult != DiffResult.Unchanged
53+
}
4754
.map { (id, ogDoc) ->
4855
// clone to avoid referencing self
4956
Document(ogDoc)

integrations/moodle/src/main/kotlin/me/snoty/integration/moodle/model/MoodleAssignement.kt

+2
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import me.snoty.integration.moodle.model.raw.MoodleEvent
88
data class MoodleAssignment(
99
val id: Long,
1010
val name: String,
11+
val description: String,
1112
val due: Instant,
1213
val state: MoodleAssignmentState
1314
)
@@ -27,6 +28,7 @@ fun MoodleEvent.toMoodleAssignment(): MoodleAssignment {
2728
return MoodleAssignment(
2829
id = id,
2930
name = name,
31+
description = description,
3032
due = Instant.fromEpochSeconds(timeStart),
3133
state = when {
3234
overdue -> MoodleAssignmentState.OVERDUE

integrations/moodle/src/main/kotlin/me/snoty/integration/moodle/model/raw/MoodleEvent.kt

+1
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ data class MoodleEvent(
88
val id: Long,
99
@SerialName("activityname")
1010
val name: String,
11+
val description: String,
1112
@SerialName("timestart")
1213
val timeStart: Long,
1314
val overdue: Boolean,

integrations/todoist/build.gradle.kts

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
plugins {
2+
id("snoty.integration-conventions")
3+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package me.snoty.integration.todoist
2+
3+
import io.ktor.client.*
4+
import io.ktor.client.call.*
5+
import io.ktor.client.request.*
6+
import io.ktor.client.statement.*
7+
import io.ktor.http.*
8+
import me.snoty.integration.todoist.model.TodoistTaskCreateDTO
9+
import me.snoty.integration.todoist.model.TodoistTaskCreateResponse
10+
import me.snoty.integration.todoist.model.TodoistTaskUpdateDTO
11+
import org.koin.core.annotation.Factory
12+
import org.koin.core.annotation.InjectedParam
13+
14+
interface TodoistAPI {
15+
suspend fun createTask(taskCreateDTO: TodoistTaskCreateDTO): TodoistTaskCreateResponse
16+
suspend fun updateTask(id: String, taskUpdateDTO: TodoistTaskUpdateDTO): HttpResponse
17+
suspend fun closeTask(id: String): HttpResponse
18+
}
19+
20+
@Factory
21+
class TodoistAPIImpl(val client: HttpClient, @InjectedParam private val token: String) : TodoistAPI {
22+
override suspend fun createTask(taskCreateDTO: TodoistTaskCreateDTO): TodoistTaskCreateResponse =
23+
client.post("https://api.todoist.com/rest/v2/tasks") {
24+
header("Authorization", "Bearer $token")
25+
contentType(ContentType.Application.Json)
26+
accept(ContentType.Application.Json)
27+
setBody(taskCreateDTO)
28+
}.body()
29+
30+
override suspend fun updateTask(id: String, taskUpdateDTO: TodoistTaskUpdateDTO) =
31+
client.post("https://api.todoist.com/rest/v2/tasks/$id") {
32+
header("Authorization", "Bearer $token")
33+
contentType(ContentType.Application.Json)
34+
setBody(taskUpdateDTO)
35+
}
36+
37+
override suspend fun closeTask(id: String) =
38+
client.post("https://api.todoist.com/rest/v2/tasks/$id/close") {
39+
header("Authorization", "Bearer $token")
40+
}
41+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package me.snoty.integration.todoist
2+
3+
import kotlinx.serialization.Serializable
4+
import me.snoty.backend.utils.orNull
5+
import me.snoty.integration.todoist.model.TodoistTaskCreateDTO
6+
import me.snoty.integration.todoist.model.TodoistTaskUpdateDTO
7+
8+
@Serializable
9+
data class TodoistInput(
10+
val id: String,
11+
12+
val parentId: String?,
13+
val content: String,
14+
val description: String?,
15+
val labels: List<String>?,
16+
val priority: Int?,
17+
val dueDate: String?,
18+
val dueDateTime: String?,
19+
)
20+
21+
fun TodoistInput.toTaskCreateDTO(projectId: String?, sectionId: String?) = TodoistTaskCreateDTO(
22+
content = content,
23+
description = description,
24+
labels = labels,
25+
priority = priority,
26+
dueDate = dueDate,
27+
dueDateTime = dueDateTime,
28+
projectId = projectId.orNull(),
29+
sectionId = sectionId.orNull(),
30+
parentId = parentId,
31+
)
32+
33+
fun TodoistInput.toTaskUpdateDTO() = TodoistTaskUpdateDTO(
34+
content = content,
35+
description = description,
36+
labels = labels ?: emptyList(),
37+
priority = priority,
38+
dueDate = dueDate,
39+
dueDateTime = dueDateTime,
40+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package me.snoty.integration.todoist
2+
3+
import io.ktor.client.*
4+
import kotlinx.coroutines.flow.toList
5+
import kotlinx.serialization.Serializable
6+
import me.snoty.integration.common.annotation.RegisterNode
7+
import me.snoty.integration.common.diff.DiffResult
8+
import me.snoty.integration.common.model.NodePosition
9+
import me.snoty.integration.common.model.metadata.FieldCensored
10+
import me.snoty.integration.common.wiring.Node
11+
import me.snoty.integration.common.wiring.NodeHandleContext
12+
import me.snoty.integration.common.wiring.data.IntermediateData
13+
import me.snoty.integration.common.wiring.data.NodeOutput
14+
import me.snoty.integration.common.wiring.get
15+
import me.snoty.integration.common.wiring.node.NodeHandler
16+
import me.snoty.integration.common.wiring.node.NodePersistenceFactory
17+
import me.snoty.integration.common.wiring.node.NodeSettings
18+
import me.snoty.integration.common.wiring.node.invoke
19+
import org.bson.Document
20+
import org.koin.core.annotation.Single
21+
22+
@Serializable
23+
data class TodoistSettings(
24+
override val name: String = "Todoist",
25+
@FieldCensored
26+
val token: String,
27+
val projectId: String?,
28+
val sectionId: String?,
29+
) : NodeSettings
30+
31+
@RegisterNode(
32+
type = "todoist",
33+
displayName = "Todoist",
34+
position = NodePosition.END,
35+
settingsType = TodoistSettings::class,
36+
inputType = TodoistInput::class,
37+
)
38+
@Single
39+
class TodoistNodeHandler(
40+
private val httpClient: HttpClient,
41+
private val apiFactory: (String) -> TodoistAPI = { TodoistAPIImpl(httpClient, it) },
42+
persistenceFactory: NodePersistenceFactory,
43+
) : NodeHandler {
44+
data class Task(val externalId: String, val todoistId: String)
45+
46+
private val taskService = persistenceFactory<Task>("tasks")
47+
48+
override suspend fun NodeHandleContext.process(node: Node, input: Collection<IntermediateData>): NodeOutput {
49+
val settings = node.settings as TodoistSettings
50+
val api = apiFactory(settings.token)
51+
52+
val tasks = taskService.getEntities(node).toList()
53+
input.forEach {
54+
val data = get<TodoistInput>(it)
55+
val diff = get<Document>(it)["diff"] as DiffResult
56+
57+
suspend fun create() {
58+
api.createTask(data.toTaskCreateDTO(
59+
projectId = settings.projectId,
60+
sectionId = settings.sectionId,
61+
))
62+
.also { response ->
63+
taskService.persistEntity(
64+
node = node,
65+
entityId = data.id,
66+
entity = Task(externalId = data.id, todoistId = response.id),
67+
)
68+
}
69+
logger.info("Created task: {}", data.content)
70+
}
71+
72+
when (diff) {
73+
is DiffResult.Created -> create()
74+
is DiffResult.Updated -> {
75+
tasks.getTodoistId(data.id)
76+
?.let { taskId ->
77+
api.updateTask(taskId, data.toTaskUpdateDTO())
78+
logger.info("Updated task: {}", data.content)
79+
}
80+
?: create()
81+
}
82+
is DiffResult.Deleted -> {
83+
val taskId = tasks.getTodoistId(data.id)
84+
?: run {
85+
logger.error("Task {} for entity {} not found", data.content, data.id)
86+
return@forEach
87+
}
88+
api.closeTask(taskId)
89+
logger.info("Closed task: {}", data.content)
90+
}
91+
is DiffResult.Unchanged -> {}
92+
}
93+
}
94+
95+
return emptyList()
96+
}
97+
98+
private fun List<Task>.getTodoistId(externalId: String) =
99+
firstOrNull { it.externalId == externalId }?.todoistId
100+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package me.snoty.integration.todoist.model
2+
3+
import kotlinx.serialization.SerialName
4+
import kotlinx.serialization.Serializable
5+
6+
@Serializable
7+
/**
8+
* [Todoist REST Docs](https://developer.todoist.com/rest/v2/#create-a-new-task)
9+
*/
10+
data class TodoistTaskCreateDTO(
11+
val content: String,
12+
val description: String?,
13+
@SerialName("project_id")
14+
val projectId: String?,
15+
@SerialName("section_id")
16+
val sectionId: String?,
17+
@SerialName("parent_id")
18+
val parentId: String?,
19+
@SerialName("labels")
20+
val labels: List<String>?,
21+
@SerialName("priority")
22+
val priority: Int?,
23+
@SerialName("due_date")
24+
val dueDate: String?,
25+
@SerialName("due_datetime")
26+
val dueDateTime: String?,
27+
)
28+
29+
@Serializable
30+
data class TodoistTaskCreateResponse(
31+
val id: String
32+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package me.snoty.integration.todoist.model
2+
3+
import kotlinx.serialization.Serializable
4+
5+
@Serializable
6+
data class TodoistTaskUpdateDTO(
7+
val content: String?,
8+
val description: String?,
9+
val labels: List<String>?,
10+
val priority: Int?,
11+
val dueDate: String?,
12+
val dueDateTime: String?,
13+
)

src/main/kotlin/me/snoty/backend/wiring/node/MongoNodePersistenceService.kt

+2-2
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import me.snoty.integration.common.wiring.node.NodeDescriptor
1515
import me.snoty.integration.common.wiring.node.NodePersistenceFactory
1616
import me.snoty.integration.common.wiring.node.NodePersistenceService
1717
import org.bson.codecs.pojo.annotations.BsonId
18-
import org.koin.core.annotation.Single
18+
import org.koin.core.annotation.Factory
1919
import kotlin.reflect.KClass
2020

2121
private data class NodeEntities<T>(
@@ -56,7 +56,7 @@ class MongoNodePersistenceService<T : Any>(
5656
}
5757
}
5858

59-
@Single
59+
@Factory
6060
class MongoNodePersistenceFactory(private val mongoDB: MongoDatabase, private val nodeDescriptor: NodeDescriptor) : NodePersistenceFactory {
6161
override fun <T : Any> create(name: String, entityClass: KClass<T>): NodePersistenceService<T> {
6262
return MongoNodePersistenceService(mongoDB, nodeDescriptor, name, entityClass)

0 commit comments

Comments
 (0)