package org.terst.feedreader.stash import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.terst.feedreader.data.await import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import java.util.concurrent.TimeUnit private val json = Json { ignoreUnknownKeys = true } @Serializable private data class CreateArtifactRequest( val type: String = "url", @SerialName("source_url") val sourceUrl: String, val title: String? = null ) @Serializable data class ArtifactResponse( val id: String, val title: String? = null, @SerialName("content_md") val contentMd: String? = null, @SerialName("processing_steps") val processingSteps: List = emptyList() ) @Serializable private data class DuplicateErrorResponse(@SerialName("existing_id") val existingId: String) sealed class StashSaveResult { data class Saved(val artifact: ArtifactResponse) : StashSaveResult() data class AlreadySaved(val existingId: String) : StashSaveResult() data class Failed(val message: String) : StashSaveResult() } sealed class StashVerifyResult { object Valid : StashVerifyResult() data class Invalid(val message: String) : StashVerifyResult() } /** * Client for the parts of Stash's API this app needs: POST /artifacts to * archive an article. Uses sync=true so content_md comes back in the same * response (server runs extract+summary_lite inline, up to ~30s) -- * tags/full-summary/embeddings still fill in async server-side afterward, * but this app doesn't need to wait on or poll for those. */ class StashClient( private val client: OkHttpClient, private val baseUrl: String, private val apiKey: String ) { // A cheap authenticated GET against the same collection /artifacts POSTs to, rather than // a dedicated /me or /health endpoint this API doesn't document -- reads are safe/free of // side effects, unlike actually creating an artifact just to test the key works. suspend fun verifyCredentials(): StashVerifyResult = withContext(Dispatchers.IO) { if (baseUrl.isBlank() || apiKey.isBlank()) { return@withContext StashVerifyResult.Invalid("Base URL and API key are both required") } val request = Request.Builder() .url("$baseUrl/artifacts") .header("X-API-Key", apiKey) .get() .build() runCatching { client.newCall(request).await().use { response -> when { response.isSuccessful -> StashVerifyResult.Valid response.code == 401 || response.code == 403 -> StashVerifyResult.Invalid("Rejected: API key not accepted") else -> StashVerifyResult.Invalid("HTTP ${response.code}") } } }.getOrElse { StashVerifyResult.Invalid(it.message ?: "Couldn't reach that URL") } } suspend fun saveArticle(url: String, title: String?): StashSaveResult = withContext(Dispatchers.IO) { if (baseUrl.isBlank() || apiKey.isBlank()) { return@withContext StashSaveResult.Failed("Stash isn't configured (set base URL and API key in Settings)") } val body = json.encodeToString( CreateArtifactRequest.serializer(), CreateArtifactRequest(sourceUrl = url, title = title) ).toRequestBody("application/json".toMediaType()) val request = Request.Builder() .url("$baseUrl/artifacts?sync=true") .header("X-API-Key", apiKey) .post(body) .build() runCatching { val call = client.newCall(request) // sync=true makes the server run extract+summary_lite inline before responding // (documented above as taking up to ~30s), but `client` is the shared Net.client // used for feed fetching too, whose default read timeout (OkHttp's stock 10s) is // sized for a normal feed request, not this. Overriding per-call, not on the // shared client, so feed fetches don't inherit a 35s timeout they don't need. call.timeout().timeout(35, TimeUnit.SECONDS) call.await().use { response -> val responseBody = response.body?.string().orEmpty() when { response.code == 201 || response.code == 200 -> StashSaveResult.Saved(json.decodeFromString(ArtifactResponse.serializer(), responseBody)) response.code == 409 -> // The status code alone already tells us definitively this was // rejected as a duplicate -- that's true regardless of whether the // body happens to match DuplicateErrorResponse's exact shape. Losing // the existing-artifact id to a schema mismatch shouldn't downgrade // a real "already saved" into a misleading "save failed". StashSaveResult.AlreadySaved( runCatching { json.decodeFromString(DuplicateErrorResponse.serializer(), responseBody).existingId }.getOrDefault("") ) else -> StashSaveResult.Failed("HTTP ${response.code}") } } }.getOrElse { StashSaveResult.Failed(it.message ?: "Network error") } } }