You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

176 lines
5.8 KiB

2 years ago
  1. package net.aiterp.git.ykonsole2.infrastructure.indigo1
  2. import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
  3. import kotlinx.coroutines.future.await
  4. import me.lazmaid.kraph.Kraph
  5. import net.aiterp.git.ykonsole2.StorageException
  6. import net.aiterp.git.ykonsole2.application.logging.log
  7. import net.aiterp.git.ykonsole2.domain.models.Device
  8. import net.aiterp.git.ykonsole2.domain.models.Program
  9. import net.aiterp.git.ykonsole2.domain.models.Workout
  10. import net.aiterp.git.ykonsole2.domain.models.WorkoutState
  11. import net.aiterp.git.ykonsole2.infrastructure.ExportTarget
  12. import java.net.URI
  13. import java.net.http.HttpClient
  14. import java.net.http.HttpRequest
  15. import java.net.http.HttpRequest.BodyPublishers
  16. import java.net.http.HttpResponse.BodyHandlers
  17. import java.time.LocalDate
  18. import java.time.LocalTime
  19. import java.time.ZoneId
  20. import java.time.ZoneOffset
  21. import java.time.temporal.ChronoUnit
  22. import java.util.*
  23. class Indigo1(
  24. private val endpoint: String,
  25. private val clientId: String,
  26. private val clientSecret: String,
  27. ) : ExportTarget {
  28. private val logger = log
  29. override suspend fun isExported(workout: Workout): Boolean {
  30. val ids = run {
  31. query {
  32. fieldObject(
  33. "exercises", args = mapOf(
  34. "filter" to mapOf(
  35. "fromDate" to workout.createdAt.minus(7, ChronoUnit.DAYS).atZone(ZoneOffset.UTC).toLocalDate().toString(),
  36. "kindId" to 3,
  37. "tags" to listOf(tag("ykonsole:Version", "2"), tag("ykonsole:WorkoutID", workout.id)),
  38. )
  39. )
  40. ) {
  41. field("id")
  42. }
  43. }
  44. }.data.exercises ?: return false
  45. return ids.isNotEmpty()
  46. }
  47. override suspend fun export(
  48. workout: Workout,
  49. workoutStates: List<WorkoutState>,
  50. device: Device?,
  51. program: Program?,
  52. ) {
  53. logger.info("Creating exercise...")
  54. val exerciseId = run {
  55. mutation {
  56. fieldObject(
  57. "addExercise", args = mapOf(
  58. "options" to mapOf(
  59. "kindId" to 3,
  60. "partOfDayId" to workout.partOfDayId,
  61. "date" to workout.date.toString(),
  62. ),
  63. ),
  64. ) { field("id") }
  65. }
  66. }.data.addExercise?.id ?: throw StorageException("Failed to create exercise")
  67. logger.info("Created exercise with ID $exerciseId")
  68. logger.info("Exporting states for exercise with ID $exerciseId...")
  69. for (chunk in workoutStates.chunked(100)) {
  70. val calories = chunk.mapNotNull { ws ->
  71. if (ws.calories != null) (ws.time.toInt() to ws.calories!!.toInt()) else null
  72. }
  73. val distance = chunk.mapNotNull { ws ->
  74. if (ws.distance != null) (ws.time.toInt() to ws.distance!!.toInt().toDouble() / 1000) else null
  75. }
  76. if (calories.isNotEmpty()) {
  77. run {
  78. mutation {
  79. fieldObject(
  80. "addMeasurementBatch", args = mapOf(
  81. "exerciseId" to exerciseId,
  82. "options" to calories.map { mapOf("point" to it.first, "value" to it.second) },
  83. )
  84. ) { field("id") }
  85. }
  86. }
  87. }
  88. if (distance.isNotEmpty()) {
  89. run {
  90. mutation {
  91. fieldObject(
  92. "addMetadataBatch", args = mapOf(
  93. "exerciseId" to exerciseId,
  94. "options" to distance.map { mapOf("point" to it.first, "kindId" to 5, "value" to it.second) },
  95. )
  96. ) { field("id") }
  97. }
  98. }
  99. }
  100. }
  101. logger.info("Exporting tags for exercise with ID $exerciseId...")
  102. run {
  103. mutation {
  104. fieldObject(
  105. "addTagBatch", args = mapOf(
  106. "exerciseId" to exerciseId,
  107. "options" to listOfNotNull(
  108. tag("ykonsole:Version", "2"),
  109. tag("ykonsole:WorkoutID", workout.id),
  110. workout.message.takeIf(String::isNotBlank)?.let { tag("ykonsole:ErrorMessage", it) },
  111. tag("ykonsole:DeviceID", workout.deviceId),
  112. device?.let { tag("ykonsole:DeviceName", it.name) },
  113. program?.let { tag("ykonsole:ProgramID", it.id) },
  114. program?.let { tag("ykonsole:ProgramName", it.name) },
  115. tag("ykonsole:CreatedAt", "${workout.createdAt}"),
  116. )
  117. )
  118. ) {
  119. field("id")
  120. }
  121. }
  122. }
  123. }
  124. private suspend fun run(func: Kraph.() -> Unit): Output {
  125. val query = Kraph { func() }
  126. val request = HttpRequest.newBuilder()
  127. .uri(URI.create(endpoint))
  128. .POST(BodyPublishers.ofString(query.toRequestString()))
  129. .header("Content-Type", "application/json")
  130. .header("Authorization", "Basic ${Base64.getEncoder().encodeToString("$clientId:$clientSecret".toByteArray())}")
  131. .build()
  132. val response = HttpClient.newHttpClient()
  133. .sendAsync(request, BodyHandlers.ofString())
  134. .await()
  135. return jackson.readValue(response.body(), Output::class.java)
  136. }
  137. private val jackson = jacksonObjectMapper()
  138. private data class Output(val data: Data)
  139. private data class Data(
  140. val addExercise: ExerciseOutput? = null,
  141. val addMeasurementBatch: List<ExerciseOutput>? = null,
  142. val addMetadataBatch: List<ExerciseOutput>? = null,
  143. val addTagBatch: List<ExerciseOutput>? = null,
  144. val exercises: List<ExerciseOutput>? = null,
  145. )
  146. private data class ExerciseOutput(val id: Int)
  147. private val Workout.partOfDayId
  148. get() = when (LocalTime.ofInstant(createdAt, zone).hour) {
  149. in 5..11 -> "M"
  150. in 12..17 -> "A"
  151. in 18..22 -> "E"
  152. else -> "N"
  153. }
  154. private val Workout.date get() = LocalDate.ofInstant(createdAt, zone)
  155. private val zone = ZoneId.of("Europe/Oslo")
  156. private fun tag(key: String, value: String) = mapOf("key" to key, "value" to value)
  157. }