feat: add board detail and card/list mutation API methods

This commit is contained in:
2026-03-16 00:20:42 -04:00
parent 3cff919222
commit 6ea0bd1a2f
7 changed files with 874 additions and 9 deletions

View File

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