feat: add board detail and card/list mutation API methods
This commit is contained in:
@@ -3,10 +3,15 @@ package space.hackenslacker.kanbn4droid.app.auth
|
||||
import java.io.IOException
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.time.Instant
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import space.hackenslacker.kanbn4droid.app.boarddetail.BoardCardSummary
|
||||
import space.hackenslacker.kanbn4droid.app.boarddetail.BoardDetail
|
||||
import space.hackenslacker.kanbn4droid.app.boarddetail.BoardListDetail
|
||||
import space.hackenslacker.kanbn4droid.app.boarddetail.BoardTagSummary
|
||||
import space.hackenslacker.kanbn4droid.app.boards.BoardSummary
|
||||
import space.hackenslacker.kanbn4droid.app.boards.BoardTemplate
|
||||
import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult
|
||||
@@ -48,6 +53,22 @@ interface KanbnApiClient {
|
||||
suspend fun deleteBoard(baseUrl: String, apiKey: String, boardId: String): BoardsApiResult<Unit> {
|
||||
return BoardsApiResult.Failure("Board deletion is not implemented.")
|
||||
}
|
||||
|
||||
suspend fun getBoardDetail(baseUrl: String, apiKey: String, boardId: String): BoardsApiResult<BoardDetail> {
|
||||
return BoardsApiResult.Failure("Board detail is not implemented.")
|
||||
}
|
||||
|
||||
suspend fun renameList(baseUrl: String, apiKey: String, listId: String, newTitle: String): BoardsApiResult<Unit> {
|
||||
return BoardsApiResult.Failure("List rename is not implemented.")
|
||||
}
|
||||
|
||||
suspend fun moveCard(baseUrl: String, apiKey: String, cardId: String, targetListId: String): BoardsApiResult<Unit> {
|
||||
return BoardsApiResult.Failure("Card move is not implemented.")
|
||||
}
|
||||
|
||||
suspend fun deleteCard(baseUrl: String, apiKey: String, cardId: String): BoardsApiResult<Unit> {
|
||||
return BoardsApiResult.Failure("Card deletion is not implemented.")
|
||||
}
|
||||
}
|
||||
|
||||
class HttpKanbnApiClient : KanbnApiClient {
|
||||
@@ -193,6 +214,94 @@ class HttpKanbnApiClient : KanbnApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getBoardDetail(
|
||||
baseUrl: String,
|
||||
apiKey: String,
|
||||
boardId: String,
|
||||
): BoardsApiResult<BoardDetail> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
request(
|
||||
baseUrl = baseUrl,
|
||||
path = "/api/v1/boards/$boardId",
|
||||
method = "GET",
|
||||
apiKey = apiKey,
|
||||
) { code, body ->
|
||||
if (code in 200..299) {
|
||||
BoardsApiResult.Success(parseBoardDetail(body, boardId))
|
||||
} else {
|
||||
BoardsApiResult.Failure(serverMessage(body, code))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun renameList(
|
||||
baseUrl: String,
|
||||
apiKey: String,
|
||||
listId: String,
|
||||
newTitle: String,
|
||||
): BoardsApiResult<Unit> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
request(
|
||||
baseUrl = baseUrl,
|
||||
path = "/api/v1/lists/$listId",
|
||||
method = "PATCH",
|
||||
apiKey = apiKey,
|
||||
body = "{\"name\":\"${jsonEscape(newTitle.trim())}\"}",
|
||||
) { code, body ->
|
||||
if (code in 200..299) {
|
||||
BoardsApiResult.Success(Unit)
|
||||
} else {
|
||||
BoardsApiResult.Failure(serverMessage(body, code))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun moveCard(
|
||||
baseUrl: String,
|
||||
apiKey: String,
|
||||
cardId: String,
|
||||
targetListId: String,
|
||||
): BoardsApiResult<Unit> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
request(
|
||||
baseUrl = baseUrl,
|
||||
path = "/api/v1/cards/$cardId",
|
||||
method = "PATCH",
|
||||
apiKey = apiKey,
|
||||
body = "{\"listId\":\"${jsonEscape(targetListId)}\"}",
|
||||
) { code, body ->
|
||||
if (code in 200..299) {
|
||||
BoardsApiResult.Success(Unit)
|
||||
} else {
|
||||
BoardsApiResult.Failure(serverMessage(body, code))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun deleteCard(
|
||||
baseUrl: String,
|
||||
apiKey: String,
|
||||
cardId: String,
|
||||
): BoardsApiResult<Unit> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
request(
|
||||
baseUrl = baseUrl,
|
||||
path = "/api/v1/cards/$cardId",
|
||||
method = "DELETE",
|
||||
apiKey = apiKey,
|
||||
) { code, body ->
|
||||
if (code in 200..299) {
|
||||
BoardsApiResult.Success(Unit)
|
||||
} else {
|
||||
BoardsApiResult.Failure(serverMessage(body, code))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T> request(
|
||||
baseUrl: String,
|
||||
path: String,
|
||||
@@ -203,7 +312,7 @@ class HttpKanbnApiClient : KanbnApiClient {
|
||||
): BoardsApiResult<T> {
|
||||
val endpoint = "${baseUrl.trimEnd('/')}$path"
|
||||
val connection = (URL(endpoint).openConnection() as HttpURLConnection).apply {
|
||||
requestMethod = method
|
||||
configureRequestMethod(this, method)
|
||||
connectTimeout = 10_000
|
||||
readTimeout = 10_000
|
||||
setRequestProperty("x-api-key", apiKey)
|
||||
@@ -237,6 +346,18 @@ class HttpKanbnApiClient : KanbnApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
private fun configureRequestMethod(connection: HttpURLConnection, method: String) {
|
||||
try {
|
||||
connection.requestMethod = method
|
||||
} catch (throwable: Throwable) {
|
||||
if (method != "PATCH") {
|
||||
throw throwable
|
||||
}
|
||||
connection.requestMethod = "POST"
|
||||
connection.setRequestProperty("X-HTTP-Method-Override", "PATCH")
|
||||
}
|
||||
}
|
||||
|
||||
private fun readResponseBody(connection: HttpURLConnection, code: Int): String {
|
||||
val stream = if (code in 200..299) connection.inputStream else connection.errorStream
|
||||
return stream?.bufferedReader()?.use { it.readText() }.orEmpty()
|
||||
@@ -361,18 +482,295 @@ class HttpKanbnApiClient : KanbnApiClient {
|
||||
return workspaces
|
||||
}
|
||||
|
||||
private fun parseBoardDetail(body: String, fallbackId: String): BoardDetail {
|
||||
val root = parseJsonObject(body)
|
||||
?: return BoardDetail(id = fallbackId, title = "Board", lists = emptyList())
|
||||
val data = root["data"] as? Map<*, *>
|
||||
val board = (data?.get("board") as? Map<*, *>)
|
||||
?: (root["board"] as? Map<*, *>)
|
||||
?: root
|
||||
|
||||
val boardId = extractId(board).ifBlank { fallbackId }
|
||||
val boardTitle = extractTitle(board, "Board")
|
||||
val lists = parseLists(board)
|
||||
|
||||
return BoardDetail(id = boardId, title = boardTitle, lists = lists)
|
||||
}
|
||||
|
||||
private fun parseLists(board: Map<*, *>): List<BoardListDetail> {
|
||||
return extractObjectArray(board, "lists", "items", "data").mapNotNull { rawList ->
|
||||
val id = extractId(rawList)
|
||||
if (id.isBlank()) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
BoardListDetail(
|
||||
id = id,
|
||||
title = extractTitle(rawList, "List"),
|
||||
cards = parseCards(rawList),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseCards(list: Map<*, *>): List<BoardCardSummary> {
|
||||
return extractObjectArray(list, "cards", "items", "data").mapNotNull { rawCard ->
|
||||
val id = extractId(rawCard)
|
||||
if (id.isBlank()) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
BoardCardSummary(
|
||||
id = id,
|
||||
title = extractTitle(rawCard, "Card"),
|
||||
tags = parseTags(rawCard),
|
||||
dueAtEpochMillis = parseDueDate(rawCard),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseTags(card: Map<*, *>): List<BoardTagSummary> {
|
||||
return extractObjectArray(card, "labels", "tags", "data").mapNotNull { rawTag ->
|
||||
val id = extractId(rawTag)
|
||||
if (id.isBlank()) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
BoardTagSummary(
|
||||
id = id,
|
||||
name = extractTitle(rawTag, "Tag"),
|
||||
colorHex = extractString(rawTag, "colorHex", "color", "hex"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseDueDate(card: Map<*, *>): Long? {
|
||||
val dueValue = firstPresent(card, "dueDate", "dueAt", "due_at", "due") ?: return null
|
||||
return when (dueValue) {
|
||||
is Number -> dueValue.toLong()
|
||||
is String -> {
|
||||
val trimmed = dueValue.trim()
|
||||
if (trimmed.isBlank()) null else trimmed.toLongOrNull() ?: runCatching { Instant.parse(trimmed).toEpochMilli() }.getOrNull()
|
||||
}
|
||||
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractObjectArray(source: Map<*, *>, vararg keys: String): List<Map<*, *>> {
|
||||
val array = keys.firstNotNullOfOrNull { key -> source[key] as? List<*> } ?: return emptyList()
|
||||
return array.mapNotNull { it as? Map<*, *> }
|
||||
}
|
||||
|
||||
private fun extractId(source: Map<*, *>): String {
|
||||
val directId = source["id"]?.toString().orEmpty()
|
||||
if (directId.isNotBlank()) {
|
||||
return directId
|
||||
}
|
||||
return extractString(source, "publicId", "public_id")
|
||||
}
|
||||
|
||||
private fun extractTitle(source: Map<*, *>, fallback: String): String {
|
||||
return extractString(source, "title", "name").ifBlank { fallback }
|
||||
}
|
||||
|
||||
private fun extractString(source: Map<*, *>, vararg keys: String): String {
|
||||
return keys.firstNotNullOfOrNull { key -> source[key]?.toString()?.takeIf { it.isNotBlank() } }.orEmpty()
|
||||
}
|
||||
|
||||
private fun firstPresent(source: Map<*, *>, vararg keys: String): Any? {
|
||||
return keys.firstNotNullOfOrNull { key -> source[key] }
|
||||
}
|
||||
|
||||
private fun parseJsonObject(body: String): Map<String, Any?>? {
|
||||
val trimmed = body.trim()
|
||||
if (trimmed.isBlank()) {
|
||||
return null
|
||||
}
|
||||
val parsed = runCatching { MiniJsonParser(trimmed).parseValue() }.getOrNull()
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return parsed as? Map<String, Any?>
|
||||
}
|
||||
|
||||
private fun jsonEscape(value: String): String {
|
||||
val builder = StringBuilder()
|
||||
value.forEach { ch ->
|
||||
when (ch) {
|
||||
'\\' -> builder.append("\\\\")
|
||||
'"' -> builder.append("\\\"")
|
||||
'\b' -> builder.append("\\b")
|
||||
'\u000C' -> builder.append("\\f")
|
||||
'\n' -> builder.append("\\n")
|
||||
'\r' -> builder.append("\\r")
|
||||
'\t' -> builder.append("\\t")
|
||||
else -> builder.append(ch)
|
||||
}
|
||||
}
|
||||
return builder.toString()
|
||||
}
|
||||
|
||||
private class MiniJsonParser(private val input: String) {
|
||||
private var index = 0
|
||||
|
||||
fun parseValue(): Any? {
|
||||
skipWhitespace()
|
||||
if (index >= input.length) {
|
||||
return null
|
||||
}
|
||||
return when (val ch = input[index]) {
|
||||
'{' -> parseObject()
|
||||
'[' -> parseArray()
|
||||
'"' -> parseString()
|
||||
't' -> parseLiteral("true", true)
|
||||
'f' -> parseLiteral("false", false)
|
||||
'n' -> parseLiteral("null", null)
|
||||
'-', in '0'..'9' -> parseNumber()
|
||||
else -> throw IllegalArgumentException("Unexpected token $ch at index $index")
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseObject(): Map<String, Any?> {
|
||||
expect('{')
|
||||
skipWhitespace()
|
||||
val result = linkedMapOf<String, Any?>()
|
||||
if (peek() == '}') {
|
||||
index += 1
|
||||
return result
|
||||
}
|
||||
while (index < input.length) {
|
||||
val key = parseString()
|
||||
skipWhitespace()
|
||||
expect(':')
|
||||
val value = parseValue()
|
||||
result[key] = value
|
||||
skipWhitespace()
|
||||
when (peek()) {
|
||||
',' -> index += 1
|
||||
'}' -> {
|
||||
index += 1
|
||||
return result
|
||||
}
|
||||
else -> throw IllegalArgumentException("Expected , or } at index $index")
|
||||
}
|
||||
skipWhitespace()
|
||||
}
|
||||
throw IllegalArgumentException("Unclosed object")
|
||||
}
|
||||
|
||||
private fun parseArray(): List<Any?> {
|
||||
expect('[')
|
||||
skipWhitespace()
|
||||
val result = mutableListOf<Any?>()
|
||||
if (peek() == ']') {
|
||||
index += 1
|
||||
return result
|
||||
}
|
||||
while (index < input.length) {
|
||||
result += parseValue()
|
||||
skipWhitespace()
|
||||
when (peek()) {
|
||||
',' -> index += 1
|
||||
']' -> {
|
||||
index += 1
|
||||
return result
|
||||
}
|
||||
else -> throw IllegalArgumentException("Expected , or ] at index $index")
|
||||
}
|
||||
skipWhitespace()
|
||||
}
|
||||
throw IllegalArgumentException("Unclosed array")
|
||||
}
|
||||
|
||||
private fun parseString(): String {
|
||||
expect('"')
|
||||
val result = StringBuilder()
|
||||
while (index < input.length) {
|
||||
val ch = input[index++]
|
||||
when (ch) {
|
||||
'"' -> return result.toString()
|
||||
'\\' -> {
|
||||
val escaped = input.getOrNull(index++) ?: throw IllegalArgumentException("Invalid escape")
|
||||
when (escaped) {
|
||||
'"' -> result.append('"')
|
||||
'\\' -> result.append('\\')
|
||||
'/' -> result.append('/')
|
||||
'b' -> result.append('\b')
|
||||
'f' -> result.append('\u000C')
|
||||
'n' -> result.append('\n')
|
||||
'r' -> result.append('\r')
|
||||
't' -> result.append('\t')
|
||||
'u' -> {
|
||||
val hex = input.substring(index, index + 4)
|
||||
index += 4
|
||||
result.append(hex.toInt(16).toChar())
|
||||
}
|
||||
else -> throw IllegalArgumentException("Invalid escape token")
|
||||
}
|
||||
}
|
||||
else -> result.append(ch)
|
||||
}
|
||||
}
|
||||
throw IllegalArgumentException("Unclosed string")
|
||||
}
|
||||
|
||||
private fun parseNumber(): Any {
|
||||
val start = index
|
||||
if (peek() == '-') {
|
||||
index += 1
|
||||
}
|
||||
while (peek()?.isDigit() == true) {
|
||||
index += 1
|
||||
}
|
||||
var isFloating = false
|
||||
if (peek() == '.') {
|
||||
isFloating = true
|
||||
index += 1
|
||||
while (peek()?.isDigit() == true) {
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
if (peek() == 'e' || peek() == 'E') {
|
||||
isFloating = true
|
||||
index += 1
|
||||
if (peek() == '+' || peek() == '-') {
|
||||
index += 1
|
||||
}
|
||||
while (peek()?.isDigit() == true) {
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
val token = input.substring(start, index)
|
||||
return if (isFloating) token.toDouble() else token.toLong()
|
||||
}
|
||||
|
||||
private fun parseLiteral(token: String, value: Any?): Any? {
|
||||
if (!input.startsWith(token, index)) {
|
||||
throw IllegalArgumentException("Expected $token at index $index")
|
||||
}
|
||||
index += token.length
|
||||
return value
|
||||
}
|
||||
|
||||
private fun expect(expected: Char) {
|
||||
skipWhitespace()
|
||||
if (peek() != expected) {
|
||||
throw IllegalArgumentException("Expected $expected at index $index")
|
||||
}
|
||||
index += 1
|
||||
}
|
||||
|
||||
private fun peek(): Char? = input.getOrNull(index)
|
||||
|
||||
private fun skipWhitespace() {
|
||||
while (peek()?.isWhitespace() == true) {
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun serverMessage(body: String, code: Int): String {
|
||||
if (body.isBlank()) {
|
||||
return "Server error: $code"
|
||||
}
|
||||
|
||||
return runCatching {
|
||||
val root = JSONObject(body)
|
||||
listOf("message", "error", "cause", "detail")
|
||||
.firstNotNullOfOrNull { key -> root.optString(key).takeIf { it.isNotBlank() } }
|
||||
?: "Server error: $code"
|
||||
}.getOrElse {
|
||||
"Server error: $code"
|
||||
}
|
||||
val root = parseJsonObject(body)
|
||||
val message = root?.let { extractString(it, "message", "error", "cause", "detail") }.orEmpty()
|
||||
return message.ifBlank { "Server error: $code" }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user