Prep for public distribution. All user-facing strings, the README, the Gitea workflow step names/comments and the keystore example are now English. Follow-up i18n (string resources + locale files) is tracked in #5. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
149 lines
6.1 KiB
Kotlin
149 lines
6.1 KiB
Kotlin
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<String?> = 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()
|
|
}
|
|
}
|