Skip to content

Commit b7e1eaf

Browse files
committed
Vibe code Miniflux client. Sorry.
1 parent d44a9ba commit b7e1eaf

File tree

21 files changed

+745
-9
lines changed

21 files changed

+745
-9
lines changed

.idea/compiler.xml

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

.idea/misc.xml

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

capy/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ dependencies {
6666
implementation(libs.okhttp.brotli)
6767
implementation(project(":feedbinclient"))
6868
implementation(project(":feedfinder"))
69+
implementation(project(":minifluxclient"))
6970
implementation(project(":rssparser"))
7071
implementation(project(":readerclient"))
7172
testImplementation(kotlin("test"))
Lines changed: 333 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,333 @@
1+
package com.jocmp.capy.accounts.miniflux
2+
3+
import com.jocmp.capy.AccountDelegate
4+
import com.jocmp.capy.ArticleFilter
5+
import com.jocmp.capy.Feed
6+
import com.jocmp.capy.accounts.AddFeedResult
7+
import com.jocmp.capy.accounts.withErrorHandling
8+
import com.jocmp.capy.common.TimeHelpers
9+
import com.jocmp.capy.common.UnauthorizedError
10+
import com.jocmp.capy.common.host
11+
import com.jocmp.capy.common.toDateTime
12+
import com.jocmp.capy.common.transactionWithErrorHandling
13+
import com.jocmp.capy.common.withResult
14+
import com.jocmp.capy.db.Database
15+
import com.jocmp.capy.persistence.ArticleRecords
16+
import com.jocmp.capy.persistence.EnclosureRecords
17+
import com.jocmp.capy.persistence.FeedRecords
18+
import com.jocmp.minifluxclient.Category
19+
import com.jocmp.minifluxclient.CreateCategoryRequest
20+
import com.jocmp.minifluxclient.CreateFeedRequest
21+
import com.jocmp.minifluxclient.Entry
22+
import com.jocmp.minifluxclient.Miniflux
23+
import com.jocmp.minifluxclient.UpdateCategoryRequest
24+
import com.jocmp.minifluxclient.UpdateEntriesRequest
25+
import com.jocmp.minifluxclient.UpdateFeedRequest
26+
import kotlinx.coroutines.coroutineScope
27+
import kotlinx.coroutines.launch
28+
import okio.IOException
29+
import org.jsoup.Jsoup
30+
import java.time.ZonedDateTime
31+
32+
internal class MinifluxAccountDelegate(
33+
private val database: Database,
34+
private val miniflux: Miniflux
35+
) : AccountDelegate {
36+
private val articleRecords = ArticleRecords(database)
37+
private val enclosureRecords = EnclosureRecords(database)
38+
private val feedRecords = FeedRecords(database)
39+
40+
override suspend fun refresh(filter: ArticleFilter, cutoffDate: ZonedDateTime?): Result<Unit> {
41+
return try {
42+
refreshFeeds()
43+
refreshCategories()
44+
refreshArticles()
45+
46+
Result.success(Unit)
47+
} catch (exception: IOException) {
48+
Result.failure(exception)
49+
} catch (e: UnauthorizedError) {
50+
Result.failure(e)
51+
}
52+
}
53+
54+
override suspend fun markRead(articleIDs: List<String>): Result<Unit> {
55+
val entryIDs = articleIDs.map { it.toLong() }
56+
57+
return withErrorHandling {
58+
miniflux.updateEntries(UpdateEntriesRequest(entry_ids = entryIDs, status = "read"))
59+
Unit
60+
}
61+
}
62+
63+
override suspend fun markUnread(articleIDs: List<String>): Result<Unit> {
64+
val entryIDs = articleIDs.map { it.toLong() }
65+
66+
return withErrorHandling {
67+
miniflux.updateEntries(UpdateEntriesRequest(entry_ids = entryIDs, status = "unread"))
68+
Unit
69+
}
70+
}
71+
72+
override suspend fun addStar(articleIDs: List<String>): Result<Unit> {
73+
val entryIDs = articleIDs.map { it.toLong() }
74+
75+
return withErrorHandling {
76+
entryIDs.forEach { entryID ->
77+
miniflux.toggleBookmark(entryID)
78+
}
79+
Unit
80+
}
81+
}
82+
83+
override suspend fun removeStar(articleIDs: List<String>): Result<Unit> {
84+
val entryIDs = articleIDs.map { it.toLong() }
85+
86+
return withErrorHandling {
87+
entryIDs.forEach { entryID ->
88+
miniflux.toggleBookmark(entryID)
89+
}
90+
Unit
91+
}
92+
}
93+
94+
override suspend fun addFeed(
95+
url: String,
96+
title: String?,
97+
folderTitles: List<String>?
98+
): AddFeedResult {
99+
return try {
100+
val categoryId = folderTitles?.firstOrNull()?.let { folderTitle ->
101+
getOrCreateCategory(folderTitle)
102+
}
103+
104+
val response = miniflux.createFeed(
105+
CreateFeedRequest(feed_url = url, category_id = categoryId)
106+
)
107+
val createResponse = response.body()
108+
109+
if (response.code() > 300 || createResponse == null) {
110+
return AddFeedResult.Failure(AddFeedResult.Error.FeedNotFound())
111+
}
112+
113+
val feedResponse = miniflux.feed(createResponse.feed_id)
114+
val feed = feedResponse.body()
115+
116+
return if (feed != null) {
117+
upsertFeed(feed)
118+
119+
val localFeed = feedRecords.find(feed.id.toString())
120+
121+
if (localFeed != null) {
122+
coroutineScope {
123+
launch { refreshArticles() }
124+
}
125+
126+
AddFeedResult.Success(localFeed)
127+
} else {
128+
AddFeedResult.Failure(AddFeedResult.Error.SaveFailure())
129+
}
130+
} else {
131+
AddFeedResult.Failure(AddFeedResult.Error.FeedNotFound())
132+
}
133+
} catch (e: IOException) {
134+
AddFeedResult.networkError()
135+
}
136+
}
137+
138+
override suspend fun updateFeed(
139+
feed: Feed,
140+
title: String,
141+
folderTitles: List<String>,
142+
): Result<Feed> = withErrorHandling {
143+
val categoryId = folderTitles.firstOrNull()?.let { folderTitle ->
144+
getOrCreateCategory(folderTitle)
145+
}
146+
147+
miniflux.updateFeed(
148+
feedID = feed.id.toLong(),
149+
request = UpdateFeedRequest(title = title, category_id = categoryId)
150+
)
151+
152+
feedRecords.update(
153+
feedID = feed.id,
154+
title = title,
155+
)
156+
157+
feedRecords.find(feed.id)
158+
}
159+
160+
override suspend fun updateFolder(
161+
oldTitle: String,
162+
newTitle: String
163+
): Result<Unit> = withErrorHandling {
164+
// Find category by old title
165+
val categories = miniflux.categories().body() ?: emptyList()
166+
val category = categories.find { it.title == oldTitle }
167+
168+
if (category != null) {
169+
miniflux.updateCategory(
170+
categoryID = category.id,
171+
request = UpdateCategoryRequest(title = newTitle)
172+
)
173+
}
174+
175+
Unit
176+
}
177+
178+
override suspend fun removeFeed(feed: Feed): Result<Unit> = withErrorHandling {
179+
miniflux.deleteFeed(feedID = feed.id.toLong())
180+
181+
Unit
182+
}
183+
184+
override suspend fun removeFolder(folderTitle: String): Result<Unit> = withErrorHandling {
185+
val categories = miniflux.categories().body() ?: emptyList()
186+
val category = categories.find { it.title == folderTitle }
187+
188+
if (category != null) {
189+
miniflux.deleteCategory(categoryID = category.id)
190+
}
191+
192+
Unit
193+
}
194+
195+
private suspend fun refreshFeeds() {
196+
withResult(miniflux.feeds()) { feeds ->
197+
database.transactionWithErrorHandling {
198+
feeds.forEach { feed ->
199+
upsertFeed(feed)
200+
}
201+
}
202+
203+
val feedsToKeep = feeds.map { it.id.toString() }
204+
database.feedsQueries.deleteAllExcept(feedsToKeep)
205+
}
206+
}
207+
208+
private suspend fun refreshCategories() {
209+
withResult(miniflux.categories()) { categories ->
210+
database.transactionWithErrorHandling {
211+
categories.forEach { category ->
212+
database.taggingsQueries.upsert(
213+
id = category.id.toString(),
214+
feed_id = "", // Miniflux categories are not directly tied to a single feed
215+
name = category.title,
216+
)
217+
}
218+
}
219+
}
220+
}
221+
222+
private suspend fun refreshArticles() {
223+
refreshStarredEntries()
224+
refreshUnreadEntries()
225+
fetchAllEntries()
226+
}
227+
228+
private suspend fun refreshStarredEntries() {
229+
withResult(miniflux.entries(starred = true)) { result ->
230+
val ids = result.entries.map { it.id.toString() }
231+
articleRecords.markAllStarred(articleIDs = ids)
232+
}
233+
}
234+
235+
private suspend fun refreshUnreadEntries() {
236+
withResult(miniflux.entries(status = "unread")) { result ->
237+
val ids = result.entries.map { it.id.toString() }
238+
articleRecords.markAllUnread(articleIDs = ids)
239+
}
240+
}
241+
242+
private suspend fun fetchAllEntries() {
243+
var offset = 0
244+
val limit = MAX_ENTRY_LIMIT
245+
246+
do {
247+
val response = miniflux.entries(
248+
limit = limit,
249+
offset = offset,
250+
order = "published_at",
251+
direction = "desc"
252+
)
253+
val result = response.body()
254+
255+
if (result != null) {
256+
saveEntries(result.entries)
257+
offset += limit
258+
259+
// Continue if we got a full page
260+
if (result.entries.size < limit) {
261+
break
262+
}
263+
} else {
264+
break
265+
}
266+
} while (true)
267+
}
268+
269+
private fun saveEntries(entries: List<Entry>) {
270+
database.transactionWithErrorHandling {
271+
entries.forEach { entry ->
272+
val updated = TimeHelpers.nowUTC()
273+
val articleID = entry.id.toString()
274+
275+
database.articlesQueries.create(
276+
id = articleID,
277+
feed_id = entry.feed_id.toString(),
278+
title = Jsoup.parse(entry.title).text(),
279+
author = entry.author,
280+
content_html = entry.content,
281+
extracted_content_url = null,
282+
url = entry.url,
283+
summary = null,
284+
image_url = null,
285+
published_at = entry.published_at.toDateTime?.toEpochSecond(),
286+
)
287+
288+
articleRecords.createStatus(
289+
articleID = articleID,
290+
updatedAt = updated,
291+
read = entry.status == "read"
292+
)
293+
294+
entry.enclosures?.forEach { enclosure ->
295+
enclosureRecords.create(
296+
url = enclosure.url,
297+
type = enclosure.mime_type,
298+
articleID = articleID,
299+
itunesDurationSeconds = null,
300+
itunesImage = null,
301+
)
302+
}
303+
}
304+
}
305+
}
306+
307+
private fun upsertFeed(feed: com.jocmp.minifluxclient.Feed) {
308+
database.feedsQueries.upsert(
309+
id = feed.id.toString(),
310+
subscription_id = feed.id.toString(),
311+
title = feed.title,
312+
feed_url = feed.feed_url,
313+
site_url = feed.site_url,
314+
favicon_url = feed.icon?.data
315+
)
316+
}
317+
318+
private suspend fun getOrCreateCategory(title: String): Long {
319+
val categories = miniflux.categories().body() ?: emptyList()
320+
val existing = categories.find { it.title == title }
321+
322+
return if (existing != null) {
323+
existing.id
324+
} else {
325+
val response = miniflux.createCategory(CreateCategoryRequest(title = title))
326+
response.body()?.id ?: throw IOException("Failed to create category")
327+
}
328+
}
329+
330+
companion object {
331+
const val MAX_ENTRY_LIMIT = 100
332+
}
333+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package com.jocmp.capy.accounts.miniflux
2+
3+
import com.jocmp.capy.accounts.Credentials
4+
import com.jocmp.capy.accounts.Source
5+
import com.jocmp.minifluxclient.Miniflux
6+
7+
internal data class MinifluxCredentials(
8+
override val username: String,
9+
override val secret: String,
10+
override val url: String,
11+
) : Credentials {
12+
override val clientCertAlias: String = ""
13+
14+
override val source: Source = Source.MINIFLUX
15+
16+
override suspend fun verify(): Result<Credentials> {
17+
val verified = Miniflux.verifyCredentials(
18+
username = username,
19+
password = secret,
20+
baseURL = url
21+
)
22+
23+
return if (verified) {
24+
Result.success(this)
25+
} else {
26+
Result.failure(Throwable("Failed to verify Miniflux credentials"))
27+
}
28+
}
29+
}

0 commit comments

Comments
 (0)