package eu.geyskens.pdfscan.paperless import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.delay import kotlinx.coroutines.withContext import okhttp3.MediaType.Companion.toMediaType import okhttp3.MultipartBody import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.asRequestBody import org.json.JSONArray import org.json.JSONObject import java.io.File import java.util.concurrent.TimeUnit /** * Minimal Paperless-ngx REST client. Talks only to the user-configured, self-hosted * instance. The token is sent as an `Authorization: Token …` header over the connection * the user set up (HTTPS strongly recommended — see SettingsScreen guidance). */ class PaperlessClient( private val baseUrl: String, private val token: String, ) { private val client = OkHttpClient.Builder() .connectTimeout(15, TimeUnit.SECONDS) .writeTimeout(60, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS) .build() private val root = baseUrl.trim().trimEnd('/') sealed interface Result { /** Document was consumed and created in Paperless. */ data object Success : Result /** Paperless rejected it as a duplicate of an existing document. */ data object Duplicate : Result /** Upload was accepted but still processing when we stopped waiting. */ data object Pending : Result data class Failure(val message: String) : Result } /** Verifies the URL + token by hitting an authenticated endpoint. */ suspend fun testConnection(): Result = withContext(Dispatchers.IO) { val request = Request.Builder() .url("$root/api/documents/?page_size=1") .header("Authorization", "Token $token") .header("Accept", "application/json") .get() .build() runCatching { client.newCall(request).execute().use { resp -> when { resp.isSuccessful -> Result.Success resp.code == 401 || resp.code == 403 -> Result.Failure("Invalid token (${resp.code}).") else -> Result.Failure("Server responded with ${resp.code}.") } } }.getOrElse { Result.Failure(it.message ?: "Connection failed.") } } /** * Uploads a PDF and then waits for Paperless to actually consume it. The upload * endpoint only queues the document and returns a task UUID; we poll the task so the * user learns whether it was added, was a duplicate, or failed. */ suspend fun upload(pdf: File, title: String): Result = withContext(Dispatchers.IO) { val body = MultipartBody.Builder() .setType(MultipartBody.FORM) .addFormDataPart("title", title) .addFormDataPart( "document", "$title.pdf", pdf.asRequestBody("application/pdf".toMediaType()) ) .build() val request = Request.Builder() .url("$root/api/documents/post_document/") .header("Authorization", "Token $token") .post(body) .build() // The POST runs NonCancellable so that "stop waiting" only skips the status poll — // the document is always really queued. post_document returns the task UUID string. val posted: kotlin.Result = withContext(NonCancellable) { runCatching { client.newCall(request).execute().use { resp -> if (!resp.isSuccessful) error("Upload failed: ${resp.code} ${resp.message}") resp.body?.string()?.trim()?.trim('"')?.takeIf { it.isNotBlank() } } } } val taskId = posted.getOrElse { return@withContext Result.Failure(it.message ?: "Upload failed.") } // Older servers may not return a usable id; then we can only confirm acceptance. if (taskId == null || taskId.equals("OK", ignoreCase = true)) return@withContext Result.Pending pollTask(taskId) } /** Polls /api/tasks/ until the consume task finishes, or gives up after ~45s. */ private suspend fun pollTask(taskId: String): Result { val deadline = System.currentTimeMillis() + 45_000 while (System.currentTimeMillis() < deadline) { val task = runCatching { val req = Request.Builder() .url("$root/api/tasks/?task_id=$taskId") .header("Authorization", "Token $token") .header("Accept", "application/json") .get() .build() client.newCall(req).execute().use { resp -> if (resp.isSuccessful) firstTask(resp.body?.string()) else null } }.getOrNull() when (task?.status?.uppercase()) { "SUCCESS" -> return Result.Success "FAILURE" -> { val msg = task.result.orEmpty() return if (msg.contains("duplicate", ignoreCase = true)) Result.Duplicate else Result.Failure(msg.ifBlank { "Paperless could not process the document." }) } // PENDING / STARTED / RETRY / unknown → keep waiting. } delay(1500) } return Result.Pending } private data class Task(val status: String?, val result: String?) /** Parses the first task object from the /api/tasks/ response (a plain array). */ private fun firstTask(json: String?): Task? { if (json.isNullOrBlank()) return null return runCatching { val trimmed = json.trim() val arr = if (trimmed.startsWith("[")) JSONArray(trimmed) else JSONObject(trimmed).optJSONArray("results") if (arr == null || arr.length() == 0) return null val o = arr.getJSONObject(0) Task( status = o.optString("status").takeIf { it.isNotBlank() && it != "null" }, result = o.optString("result").takeIf { it.isNotBlank() && it != "null" }, ) }.getOrNull() } }