diff --git a/app/build.gradle.kts b/app/build.gradle.kts index cecd21bf..b4c208ea 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -58,6 +58,7 @@ dependencies { implementation("androidx.activity:activity-compose:1.8.2") implementation("androidx.compose.material3:material3") implementation("androidx.compose.ui:ui") + implementation("androidx.compose.material:material:1.5.4") implementation("androidx.compose.ui:ui-graphics") implementation("androidx.compose.ui:ui-tooling-preview") implementation("androidx.core:core-ktx:1.12.0") diff --git a/app/src/main/java/com/jocmp/basilreader/ui/accounts/AccountViewModel.kt b/app/src/main/java/com/jocmp/basilreader/ui/accounts/AccountViewModel.kt index 2ed8217d..c9d97db5 100644 --- a/app/src/main/java/com/jocmp/basilreader/ui/accounts/AccountViewModel.kt +++ b/app/src/main/java/com/jocmp/basilreader/ui/accounts/AccountViewModel.kt @@ -13,8 +13,9 @@ import com.jocmp.basil.AccountManager import com.jocmp.basil.Article import com.jocmp.basil.Feed import com.jocmp.basil.FeedFormEntry +import com.jocmp.basil.Filter import com.jocmp.basil.Folder -import com.jocmp.basil.feedPagingSource +import com.jocmp.basil.buildPager import com.jocmp.basilreader.selectAccount import com.jocmp.basilreader.selectedAccount import kotlinx.coroutines.flow.first @@ -29,18 +30,22 @@ class AccountViewModel( policy = neverEqualPolicy() ) + private val pager: MutableState?> = mutableStateOf(null) + init { viewModelScope.launch { val accountID = settings.data.first().selectedAccount() if (accountID != null) { - updateState(accountID) + selectAccount(accountID) } else { val firstID = accountManager.accountIDs().firstOrNull() firstID?.let { - selectAccount(firstID) + selectSettingsAccount(firstID) } } + + pager.value = account?.buildPager() } } @@ -54,8 +59,6 @@ class AccountViewModel( private val articleState = mutableStateOf(null) - private val pager = mutableStateOf?>(null) - val feeds: List get() = account?.feeds?.toList() ?: emptyList() @@ -65,29 +68,42 @@ class AccountViewModel( fun articles(): Pager? = pager.value fun selectFeed(feedID: String, onComplete: () -> Unit) { - this.feedID.value = feedID + val feed = account?.findFeed(feedID) + + this.feedID.value = feed?.id + + if (feed != null) { + pager.value = account?.buildPager( + filter = Filter.Feeds(feed = feed, status = Filter.Status.ALL), + ) + } - pager.value = account?.feedPagingSource(this.feedID.value) clearArticle() onComplete() } - suspend fun selectArticle(articleID: String) { - articleState.value = account?.findArticle(articleID.toLong(), feedID.value?.toLongOrNull()) + suspend fun refreshFeed() { + val id = feedID.value ?: return + + account?.refreshFeed(id) + } + + fun selectArticle(articleID: String) { + articleState.value = account?.findArticle(articleID.toLong()) } fun clearArticle() { articleState.value = null } - private fun selectAccount(accountID: String) { + private fun selectSettingsAccount(accountID: String) { viewModelScope.launch { settings.selectAccount(accountID) } } - private fun updateState(accountID: String) { + private fun selectAccount(accountID: String) { accountState.value = accountManager.findByID(accountID) } diff --git a/app/src/main/java/com/jocmp/basilreader/ui/articles/ArticleList.kt b/app/src/main/java/com/jocmp/basilreader/ui/articles/ArticleList.kt index 97017bdb..a6aa67df 100644 --- a/app/src/main/java/com/jocmp/basilreader/ui/articles/ArticleList.kt +++ b/app/src/main/java/com/jocmp/basilreader/ui/articles/ArticleList.kt @@ -1,13 +1,21 @@ package com.jocmp.basilreader.ui.articles import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -15,31 +23,53 @@ import androidx.paging.Pager import androidx.paging.compose.collectAsLazyPagingItems import com.jocmp.basil.Article import kotlinx.coroutines.launch +import java.time.format.DateTimeFormatter +@OptIn(ExperimentalMaterialApi::class) @Composable fun ArticleList( pager: Pager, + onRefresh: suspend () -> Unit, onSelect: suspend (articleID: String) -> Unit, ) { val composableScope = rememberCoroutineScope() val lazyPagingItems = pager.flow.collectAsLazyPagingItems() - LazyColumn(Modifier.fillMaxWidth()) { - items(count = lazyPagingItems.itemCount) { index -> - val item = lazyPagingItems[index] - Column( - modifier = Modifier - .padding(8.dp) - .clickable { - item?.let { - composableScope.launch { - onSelect(it.id) + val refreshScope = rememberCoroutineScope() + val (refreshing, setRefreshing) = remember { mutableStateOf(false) } + + fun refresh() = refreshScope.launch { + setRefreshing(true) + onRefresh() + setRefreshing(false) + } + + val state = rememberPullRefreshState(refreshing, ::refresh) + + Box(Modifier.pullRefresh(state)) { + + LazyColumn(Modifier.fillMaxWidth()) { + items(count = lazyPagingItems.itemCount) { index -> + val item = lazyPagingItems[index] + Column( + modifier = Modifier + .padding(8.dp) + .clickable { + item?.let { + composableScope.launch { + onSelect(it.id) + } } } + ) { + item?.let { article -> + Text(article.title, fontSize = 20.sp) + Text(article.arrivedAt.format(DateTimeFormatter.BASIC_ISO_DATE)) } - ) { - Text(item?.title ?: "No title", fontSize = 20.sp) + } } } + + PullRefreshIndicator(refreshing, state, Modifier.align(Alignment.TopCenter)) } } diff --git a/app/src/main/java/com/jocmp/basilreader/ui/articles/ArticleScreen.kt b/app/src/main/java/com/jocmp/basilreader/ui/articles/ArticleScreen.kt index 6c5e85f5..ec845942 100644 --- a/app/src/main/java/com/jocmp/basilreader/ui/articles/ArticleScreen.kt +++ b/app/src/main/java/com/jocmp/basilreader/ui/articles/ArticleScreen.kt @@ -51,6 +51,9 @@ fun ArticleScreen( viewModel.articles()?.let { pager -> ArticleList( pager = pager, + onRefresh = { + viewModel.refreshFeed() + }, onSelect = { viewModel.selectArticle(it) navigateToDetail() diff --git a/basil/src/main/java/com/jocmp/basil/Account.kt b/basil/src/main/java/com/jocmp/basil/Account.kt index 05fb0617..ae8e1c70 100644 --- a/basil/src/main/java/com/jocmp/basil/Account.kt +++ b/basil/src/main/java/com/jocmp/basil/Account.kt @@ -2,13 +2,15 @@ package com.jocmp.basil import com.jocmp.basil.accounts.AccountDelegate import com.jocmp.basil.accounts.LocalAccountDelegate -import com.jocmp.basil.articles.articleMapper -import com.jocmp.basil.db.Articles +import com.jocmp.basil.accounts.ParsedItem +import com.jocmp.basil.accounts.asOPML import com.jocmp.basil.db.Database -import com.jocmp.basil.feeds.FeedRecords import com.jocmp.basil.opml.Outline import com.jocmp.basil.opml.asFeed import com.jocmp.basil.opml.asFolder +import com.jocmp.basil.persistence.FeedRecords +import com.jocmp.basil.persistence.articleMapper +import com.jocmp.basil.shared.nowUTC import com.jocmp.feedfinder.FeedFinder import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.coroutineScope @@ -52,6 +54,10 @@ data class Account( addAll(folders.flatMap { it.feeds }) } + fun findFeed(feedID: String): Feed? { + return flattenedFeeds.find { it.id == feedID } + } + suspend fun addFolder(title: String): Folder { val folder = Folder(title = title) @@ -86,19 +92,8 @@ data class Account( coroutineScope { launch { val items = delegate.fetchAll(feed) - val feedID = feed.id.toLong() - - items.forEach { item -> - database.articlesQueries.create( - feed_id = feedID, - external_id = item.externalID, - title = item.title, - content_html = item.contentHTML, - url = item.url, - summary = item.summary, - image_url = item.imageURL - ) - } + + updateArticles(feed, items) } } @@ -124,16 +119,41 @@ data class Account( return feed } - suspend fun findArticle(articleID: Long?, feedID: Long?): Article? { + suspend fun refreshFeed(feedID: String) { + val feed = flattenedFeeds.find { it.id == feedID } ?: return + + val items = delegate.fetchAll(feed) + + updateArticles(feed, items) + } + + fun findArticle(articleID: Long?): Article? { articleID ?: return null return database.articlesQueries.findBy( articleID = articleID, - feedID = feedID, mapper = ::articleMapper ).executeAsOneOrNull() } + private fun updateArticles(feed: Feed, items: List) { + items.forEach { item -> + val publishedAt = item.publishedAt?.toEpochSecond() + + database.articlesQueries.create( + feed_id = feed.primaryKey, + external_id = item.externalID, + title = item.title, + content_html = item.contentHTML, + url = item.url, + summary = item.summary, + image_url = item.imageURL, + published_at = publishedAt, + arrived_at = publishedAt ?: nowUTC() + ) + } + } + private fun entrySiteURL(url: URL?): String { return url?.toString() ?: "" } diff --git a/basil/src/main/java/com/jocmp/basil/AccountPager.kt b/basil/src/main/java/com/jocmp/basil/AccountPager.kt new file mode 100644 index 00000000..e06221f2 --- /dev/null +++ b/basil/src/main/java/com/jocmp/basil/AccountPager.kt @@ -0,0 +1,19 @@ +package com.jocmp.basil + +import androidx.paging.Pager +import androidx.paging.PagingConfig +import com.jocmp.basil.persistence.ArticlePagerFactory + +fun Account.buildPager( + filter: Filter = Filter.Articles(status = Filter.Status.ALL) +): Pager { + val pagerFactory = ArticlePagerFactory( + database = database, + filter = filter + ) + + return Pager( + config = PagingConfig(pageSize = 10), + pagingSourceFactory = { pagerFactory.find() } + ) +} diff --git a/basil/src/main/java/com/jocmp/basil/Article.kt b/basil/src/main/java/com/jocmp/basil/Article.kt index 82582566..0e7b87f0 100644 --- a/basil/src/main/java/com/jocmp/basil/Article.kt +++ b/basil/src/main/java/com/jocmp/basil/Article.kt @@ -1,6 +1,7 @@ package com.jocmp.basil import java.net.URL +import java.time.ZonedDateTime data class Article( val id: String, @@ -10,5 +11,6 @@ data class Article( val contentHTML: String, val url: URL?, val summary: String, - val imageURL: URL? + val imageURL: URL?, + val arrivedAt: ZonedDateTime ) diff --git a/basil/src/main/java/com/jocmp/basil/ArticlesPagerExt.kt b/basil/src/main/java/com/jocmp/basil/ArticlesPagerExt.kt deleted file mode 100644 index b40581b3..00000000 --- a/basil/src/main/java/com/jocmp/basil/ArticlesPagerExt.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.jocmp.basil - -import androidx.paging.Pager -import androidx.paging.PagingConfig -import app.cash.sqldelight.paging3.QueryPagingSource -import com.jocmp.basil.articles.articleMapper -import kotlinx.coroutines.Dispatchers - -fun Account.feedPagingSource(feedID: String?): Pager { - return Pager( - config = PagingConfig(pageSize = 10), - pagingSourceFactory = { - QueryPagingSource( - countQuery = database.articlesQueries.countByFeed(feedID?.toLongOrNull()), - transacter = database.articlesQueries, - context = Dispatchers.IO, - queryProvider = { limit, offset -> - database.articlesQueries.allByFeed( - feedID = feedID?.toLongOrNull(), - limit = limit, - offset = offset, - mapper = ::articleMapper - ) - } - ) - } - ) -} diff --git a/basil/src/main/java/com/jocmp/basil/Feed.kt b/basil/src/main/java/com/jocmp/basil/Feed.kt index a745d164..ea64096d 100644 --- a/basil/src/main/java/com/jocmp/basil/Feed.kt +++ b/basil/src/main/java/com/jocmp/basil/Feed.kt @@ -9,6 +9,9 @@ data class Feed( val feedURL: String, val siteURL: String = "" ) { + internal val primaryKey: Long + get() = id.toLong() + override fun equals(other: Any?): Boolean { if (other is Feed) { return id == other.id @@ -20,9 +23,3 @@ data class Feed( return id.hashCode() } } - -fun Feed.asOPML(indentLevel: Int): String { - val opml = - "\n" - return opml.prepending(tabCount = indentLevel) -} diff --git a/basil/src/main/java/com/jocmp/basil/Filter.kt b/basil/src/main/java/com/jocmp/basil/Filter.kt new file mode 100644 index 00000000..4b61d12c --- /dev/null +++ b/basil/src/main/java/com/jocmp/basil/Filter.kt @@ -0,0 +1,15 @@ +package com.jocmp.basil + +sealed class Filter(val status: Status) { + enum class Status(value: String) { + ALL("all"), + READ("read"), + STARRED("starred") + } + + class Articles(status: Status) : Filter(status) + + class Feeds(val feed: Feed, status: Status) : Filter(status) + + class Folders(val folder: Folder, status: Status) : Filter(status) +} diff --git a/basil/src/main/java/com/jocmp/basil/Folder.kt b/basil/src/main/java/com/jocmp/basil/Folder.kt index 18a48eec..ff8e213d 100644 --- a/basil/src/main/java/com/jocmp/basil/Folder.kt +++ b/basil/src/main/java/com/jocmp/basil/Folder.kt @@ -19,21 +19,3 @@ data class Folder( return 31 * result } } - -fun Folder.asOPML(indentLevel: Int = 0): String { - if (feeds.isEmpty()) { - val opml = "\n" - return opml.prepending(tabCount = indentLevel) - } - - var opml = "\n" - opml = opml.prepending(tabCount = indentLevel) - - feeds.forEach { feed -> - opml += feed.asOPML(indentLevel = indentLevel + 1) - } - - opml = opml + repeatTab(tabCount = indentLevel) + "\n" - - return opml -} diff --git a/basil/src/main/java/com/jocmp/basil/accounts/AccountDelegate.kt b/basil/src/main/java/com/jocmp/basil/accounts/AccountDelegate.kt index 0b65410d..6d648478 100644 --- a/basil/src/main/java/com/jocmp/basil/accounts/AccountDelegate.kt +++ b/basil/src/main/java/com/jocmp/basil/accounts/AccountDelegate.kt @@ -1,7 +1,6 @@ package com.jocmp.basil.accounts import com.jocmp.basil.Feed -import com.jocmp.basil.feeds.ExternalFeed import java.net.URL internal interface AccountDelegate { diff --git a/basil/src/main/java/com/jocmp/basil/feeds/ExternalFeed.kt b/basil/src/main/java/com/jocmp/basil/accounts/ExternalFeed.kt similarity index 72% rename from basil/src/main/java/com/jocmp/basil/feeds/ExternalFeed.kt rename to basil/src/main/java/com/jocmp/basil/accounts/ExternalFeed.kt index 268f1747..e57b4478 100644 --- a/basil/src/main/java/com/jocmp/basil/feeds/ExternalFeed.kt +++ b/basil/src/main/java/com/jocmp/basil/accounts/ExternalFeed.kt @@ -1,4 +1,4 @@ -package com.jocmp.basil.feeds +package com.jocmp.basil.accounts internal data class ExternalFeed( val externalID: String, diff --git a/basil/src/main/java/com/jocmp/basil/accounts/FeedOPMLExt.kt b/basil/src/main/java/com/jocmp/basil/accounts/FeedOPMLExt.kt new file mode 100644 index 00000000..d6b77d2d --- /dev/null +++ b/basil/src/main/java/com/jocmp/basil/accounts/FeedOPMLExt.kt @@ -0,0 +1,10 @@ +package com.jocmp.basil.accounts + +import com.jocmp.basil.Feed +import com.jocmp.basil.shared.prepending + +fun Feed.asOPML(indentLevel: Int): String { + val opml = + "\n" + return opml.prepending(tabCount = indentLevel) +} diff --git a/basil/src/main/java/com/jocmp/basil/accounts/FolderOPMLExt.kt b/basil/src/main/java/com/jocmp/basil/accounts/FolderOPMLExt.kt new file mode 100644 index 00000000..69b1254f --- /dev/null +++ b/basil/src/main/java/com/jocmp/basil/accounts/FolderOPMLExt.kt @@ -0,0 +1,23 @@ +package com.jocmp.basil.accounts + +import com.jocmp.basil.Folder +import com.jocmp.basil.shared.prepending +import com.jocmp.basil.shared.repeatTab + +internal fun Folder.asOPML(indentLevel: Int = 0): String { + if (feeds.isEmpty()) { + val opml = "\n" + return opml.prepending(tabCount = indentLevel) + } + + var opml = "\n" + opml = opml.prepending(tabCount = indentLevel) + + feeds.forEach { feed -> + opml += feed.asOPML(indentLevel = indentLevel + 1) + } + + opml = opml + repeatTab(tabCount = indentLevel) + "\n" + + return opml +} diff --git a/basil/src/main/java/com/jocmp/basil/accounts/LocalAccountDelegate.kt b/basil/src/main/java/com/jocmp/basil/accounts/LocalAccountDelegate.kt index 1eb2649e..70d90d96 100644 --- a/basil/src/main/java/com/jocmp/basil/accounts/LocalAccountDelegate.kt +++ b/basil/src/main/java/com/jocmp/basil/accounts/LocalAccountDelegate.kt @@ -2,7 +2,7 @@ package com.jocmp.basil.accounts import com.jocmp.basil.Account import com.jocmp.basil.Feed -import com.jocmp.basil.feeds.ExternalFeed +import com.jocmp.basil.shared.parseISODate import com.prof18.rssparser.RssParser import com.prof18.rssparser.model.RssItem import java.net.URL @@ -36,7 +36,8 @@ internal class LocalAccountDelegate(private val account: Account) : AccountDeleg contentHTML = item.content, url = item.link, summary = item.description, - imageURL = item.image + imageURL = item.image, + publishedAt = parseISODate(item.pubDate) ) } } diff --git a/basil/src/main/java/com/jocmp/basil/accounts/ParsedItem.kt b/basil/src/main/java/com/jocmp/basil/accounts/ParsedItem.kt index ff2a9c2e..e045b187 100644 --- a/basil/src/main/java/com/jocmp/basil/accounts/ParsedItem.kt +++ b/basil/src/main/java/com/jocmp/basil/accounts/ParsedItem.kt @@ -1,5 +1,7 @@ package com.jocmp.basil.accounts +import java.time.OffsetDateTime + internal data class ParsedItem( val externalID: String, val title: String? = null, @@ -7,4 +9,5 @@ internal data class ParsedItem( val url: String? = null, val summary: String? = null, val imageURL: String? = null, + val publishedAt: OffsetDateTime? ) diff --git a/basil/src/main/java/com/jocmp/basil/articles/ArticleMapper.kt b/basil/src/main/java/com/jocmp/basil/persistence/ArticleMapper.kt similarity index 53% rename from basil/src/main/java/com/jocmp/basil/articles/ArticleMapper.kt rename to basil/src/main/java/com/jocmp/basil/persistence/ArticleMapper.kt index d82d0211..e595d624 100644 --- a/basil/src/main/java/com/jocmp/basil/articles/ArticleMapper.kt +++ b/basil/src/main/java/com/jocmp/basil/persistence/ArticleMapper.kt @@ -1,27 +1,35 @@ -package com.jocmp.basil.articles +package com.jocmp.basil.persistence import com.jocmp.basil.Article import com.jocmp.basil.shared.optionalURL +import java.time.Instant +import java.time.ZoneId +import java.time.ZonedDateTime internal fun articleMapper( id: Long, externalID: String, - mapperFeedID: Long?, + feedID: Long?, title: String?, contentHtml: String?, url: String?, summary: String?, imageURL: String?, - datePublished: Long? + publishedAt: Long?, + arrivedAt: Long?, + read: Boolean?, + starred: Boolean?, + zoneID: ZoneId = ZoneId.systemDefault() ): Article { return Article( id = id.toString(), externalID = externalID, - feedID = mapperFeedID.toString(), + feedID = feedID.toString(), title = title ?: "", contentHTML = contentHtml ?: "", url = optionalURL(url), imageURL = optionalURL(imageURL), - summary = summary ?: "" + summary = summary ?: "", + arrivedAt = ZonedDateTime.ofInstant(Instant.ofEpochSecond(arrivedAt!!), zoneID) ) } diff --git a/basil/src/main/java/com/jocmp/basil/persistence/ArticlePagerFactory.kt b/basil/src/main/java/com/jocmp/basil/persistence/ArticlePagerFactory.kt new file mode 100644 index 00000000..d299fa89 --- /dev/null +++ b/basil/src/main/java/com/jocmp/basil/persistence/ArticlePagerFactory.kt @@ -0,0 +1,78 @@ +package com.jocmp.basil.persistence + +import androidx.paging.PagingSource +import app.cash.sqldelight.paging3.QueryPagingSource +import com.jocmp.basil.Article +import com.jocmp.basil.Filter +import com.jocmp.basil.db.Database +import kotlinx.coroutines.Dispatchers + +internal class ArticlePagerFactory( + private val database: Database, + private val filter: Filter, +) { + fun find(): PagingSource { + return when (filter) { + is Filter.Articles -> articleSource(filter) + is Filter.Feeds -> feedSource(filter) + is Filter.Folders -> folderSource(filter) + } + } + + private fun articleSource(filter: Filter.Articles): PagingSource { + return QueryPagingSource( + countQuery = database.articlesQueries.countByStatus( + read = listOf(false), + starred = listOf(false), + ), + transacter = database.articlesQueries, + context = Dispatchers.IO, + queryProvider = { limit, offset -> + database.articlesQueries.findByStatus( + read = listOf(false), + starred = listOf(false), + limit = limit, + offset = offset, + mapper = ::articleMapper + ) + } + ) + } + + private fun feedSource(filter: Filter.Feeds): PagingSource { + val feedIDs = listOf(filter.feed.id.toLong()) + + return feedsSource(feedIDs, filter.status) + } + + private fun folderSource(filter: Filter.Folders): PagingSource { + val feedIDs = filter.folder.feeds.mapNotNull { it.id.toLongOrNull() } + + return feedsSource(feedIDs, filter.status) + } + + private fun feedsSource( + feedIDs: List, + status: Filter.Status + ): PagingSource { + return QueryPagingSource( + countQuery = database.articlesQueries.countByFeeds( + feedIDs = feedIDs, + read = listOf(false), + starred = listOf(false), + ), + transacter = database.articlesQueries, + context = Dispatchers.IO, + queryProvider = { limit, offset -> + database.articlesQueries.findByFeeds( + feedIDs = feedIDs, + read = listOf(false), + starred = listOf(false), + limit = limit, + offset = offset, + mapper = ::articleMapper + ) + } + ) + } +} diff --git a/basil/src/main/java/com/jocmp/basil/persistence/ArticleRecords.kt b/basil/src/main/java/com/jocmp/basil/persistence/ArticleRecords.kt new file mode 100644 index 00000000..f79146be --- /dev/null +++ b/basil/src/main/java/com/jocmp/basil/persistence/ArticleRecords.kt @@ -0,0 +1,8 @@ +package com.jocmp.basil.persistence + +import com.jocmp.basil.db.Database + +internal class ArticleRecords(database: Database) { + fun create() { + } +} diff --git a/basil/src/main/java/com/jocmp/basil/feeds/FeedRecords.kt b/basil/src/main/java/com/jocmp/basil/persistence/FeedRecords.kt similarity index 88% rename from basil/src/main/java/com/jocmp/basil/feeds/FeedRecords.kt rename to basil/src/main/java/com/jocmp/basil/persistence/FeedRecords.kt index 9099f2ad..a3040b63 100644 --- a/basil/src/main/java/com/jocmp/basil/feeds/FeedRecords.kt +++ b/basil/src/main/java/com/jocmp/basil/persistence/FeedRecords.kt @@ -1,7 +1,8 @@ -package com.jocmp.basil.feeds +package com.jocmp.basil.persistence import com.jocmp.basil.db.Database import com.jocmp.basil.db.Feeds +import com.jocmp.basil.accounts.ExternalFeed internal class FeedRecords(val database: Database) { internal fun findOrCreate(externalFeed: ExternalFeed): Feeds { diff --git a/basil/src/main/java/com/jocmp/basil/shared/TimeHelpers.kt b/basil/src/main/java/com/jocmp/basil/shared/TimeHelpers.kt new file mode 100644 index 00000000..beacf06b --- /dev/null +++ b/basil/src/main/java/com/jocmp/basil/shared/TimeHelpers.kt @@ -0,0 +1,19 @@ +package com.jocmp.basil.shared + +import java.time.OffsetDateTime +import java.time.ZoneOffset +import java.time.format.DateTimeParseException + +fun parseISODate(value: String?): OffsetDateTime? { + value ?: return null + + return try { + OffsetDateTime.parse(value).withOffsetSameInstant(ZoneOffset.UTC) + } catch (_: DateTimeParseException) { + null + } +} + +fun nowUTC(): Long { + return OffsetDateTime.now(ZoneOffset.UTC).toEpochSecond() +} diff --git a/basil/src/main/sqldelight/com/jocmp/basil/db/article_statuses.sq b/basil/src/main/sqldelight/com/jocmp/basil/db/article_statuses.sq new file mode 100644 index 00000000..469b5d79 --- /dev/null +++ b/basil/src/main/sqldelight/com/jocmp/basil/db/article_statuses.sq @@ -0,0 +1,9 @@ +import kotlin.Boolean; + +CREATE TABLE article_statuses ( + id INTEGER NOT NULL PRIMARY KEY, + article_id INTEGER NOT NULL REFERENCES articles(id), + read INTEGER AS Boolean DEFAULT 0, + starred INTEGER AS Boolean DEFAULT 0, + arrived_at INTEGER NOT NULL +); diff --git a/basil/src/main/sqldelight/com/jocmp/basil/db/articles.sq b/basil/src/main/sqldelight/com/jocmp/basil/db/articles.sq index 03a9915f..97b857a2 100644 --- a/basil/src/main/sqldelight/com/jocmp/basil/db/articles.sq +++ b/basil/src/main/sqldelight/com/jocmp/basil/db/articles.sq @@ -1,7 +1,5 @@ - - -CREATE TABLE articles( - id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, +CREATE TABLE IF NOT EXISTS articles( + id INTEGER NOT NULL PRIMARY KEY, external_id TEXT NOT NULL UNIQUE, feed_id INTEGER REFERENCES feeds(id), title TEXT, @@ -9,42 +7,86 @@ CREATE TABLE articles( url TEXT, summary TEXT, image_url TEXT, - date_published INTEGER + published_at INTEGER ); -countByFeed: +CREATE UNIQUE INDEX articles_external_id_index ON articles(external_id); + +countByStatus: SELECT COUNT(*) FROM articles -WHERE feed_id = :feedID; +JOIN article_statuses ON articles.id = article_statuses.article_id +WHERE article_statuses.read IN :read +AND article_statuses.starred IN :starred; -allByFeed: -SELECT * +findByStatus: +SELECT + articles.*, + article_statuses.arrived_at, + article_statuses.starred, + article_statuses.read FROM articles -WHERE feed_id = :feedID +JOIN article_statuses ON articles.id = article_statuses.article_id +AND article_statuses.read IN :read +AND article_statuses.starred IN :starred +ORDER BY article_statuses.arrived_at DESC +LIMIT :limit OFFSET :offset; + +countByFeeds: +SELECT COUNT(*) +FROM articles +JOIN article_statuses ON articles.id = article_statuses.article_id +WHERE feed_id IN :feedIDs +AND article_statuses.read IN :read +AND article_statuses.starred IN :starred; + +findByFeeds: +SELECT + articles.*, + article_statuses.arrived_at, + article_statuses.starred, + article_statuses.read +FROM articles +JOIN article_statuses ON articles.id = article_statuses.article_id +WHERE feed_id IN :feedIDs +AND article_statuses.read IN :read +AND article_statuses.starred IN :starred +ORDER BY article_statuses.arrived_at DESC LIMIT :limit OFFSET :offset; findBy: -SELECT * +SELECT + articles.*, + article_statuses.arrived_at, + article_statuses.starred, + article_statuses.read FROM articles -WHERE feed_id = :feedID AND id = :articleID +JOIN article_statuses ON articles.id = article_statuses.article_id +WHERE articles.id = :articleID LIMIT 1; -create: -REPLACE INTO articles( - feed_id, - external_id, - title, - content_html, - url, - summary, - image_url -) -VALUES ( -?, -?, -?, -?, -?, -?, -? -); +create { + REPLACE INTO articles( + feed_id, + external_id, + title, + content_html, + url, + summary, + image_url, + published_at + ) + VALUES ( + ?, + ?, + ?, + ?, + ?, + ?, + ?, + ? + ); + + INSERT OR IGNORE INTO article_statuses(article_id, arrived_at) + VALUES (last_insert_rowid(), ?); +} diff --git a/basil/src/main/sqldelight/com/jocmp/basil/db/feeds.sq b/basil/src/main/sqldelight/com/jocmp/basil/db/feeds.sq index 77554e98..b19a53d0 100644 --- a/basil/src/main/sqldelight/com/jocmp/basil/db/feeds.sq +++ b/basil/src/main/sqldelight/com/jocmp/basil/db/feeds.sq @@ -1,5 +1,5 @@ CREATE TABLE feeds ( - id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + id INTEGER NOT NULL PRIMARY KEY, external_id TEXT NOT NULL, feed_url TEXT NOT NULL UNIQUE ); diff --git a/basil/src/test/java/com/jocmp/basil/AccountTest.kt b/basil/src/test/java/com/jocmp/basil/AccountTest.kt index d96e6b78..4173f75c 100644 --- a/basil/src/test/java/com/jocmp/basil/AccountTest.kt +++ b/basil/src/test/java/com/jocmp/basil/AccountTest.kt @@ -1,6 +1,5 @@ package com.jocmp.basil -import com.jocmp.basil.accounts.AccountDelegate import com.jocmp.basil.accounts.LocalAccountDelegate import com.jocmp.basil.db.Database import com.jocmp.feedfinder.FeedFinder diff --git a/basil/src/test/java/com/jocmp/basil/persistence/ArticleMapperTest.kt b/basil/src/test/java/com/jocmp/basil/persistence/ArticleMapperTest.kt new file mode 100644 index 00000000..fd625e24 --- /dev/null +++ b/basil/src/test/java/com/jocmp/basil/persistence/ArticleMapperTest.kt @@ -0,0 +1,42 @@ +package com.jocmp.basil.persistence + +import org.junit.Test +import java.time.ZoneId +import java.time.ZonedDateTime +import kotlin.test.assertEquals + +class ArticleMapperTest { + @Test + fun `it maps to the system's timezone`() { + val zoneID = ZoneId.of("America/Chicago") + + val article = articleMapper( + id = 1, + externalID = "https://www.theverge.com/2023/12/30/24019780/vizio-settlement-effective-refresh-rate-class-action-lawsuit", + feedID = 1, + title = "Vizio agrees to pay $3 million for alleged ‘false’ refresh rate claims", + contentHtml = "

If you bought a Vizio TV in California after April 30th, 2014, Vizio may owe you some money

", + url = "https://www.theverge.com/2023/12/30/24019780/vizio-settlement-effective-refresh-rate-class-action-lawsuit", + summary = "", + imageURL = "https://cdn.vox-cdn.com/thumbor/r-eWiuX74LfGvTxwenExmwmkPlk=/0x0:1800x1200/1310x873/cdn.vox-cdn.com/uploads/chorus_image/image/73010063/Vizio_TV_D_Series_Lifestyle.0.jpg", + publishedAt = 1703960809, + arrivedAt = 1703960809, + read = false, + starred = false, + zoneID = zoneID + ) + + val expectedTime = ZonedDateTime.of( + 2023, + 12, + 30, + 12, + 26, + 49, + 0, + zoneID + ) + + assertEquals(expected = expectedTime, actual = article.arrivedAt) + } +} diff --git a/basil/src/test/java/com/jocmp/basil/shared/TimeHelpersTest.kt b/basil/src/test/java/com/jocmp/basil/shared/TimeHelpersTest.kt new file mode 100644 index 00000000..e33ef8d5 --- /dev/null +++ b/basil/src/test/java/com/jocmp/basil/shared/TimeHelpersTest.kt @@ -0,0 +1,41 @@ +package com.jocmp.basil.shared + + +import org.junit.Test +import java.time.OffsetDateTime +import java.time.ZoneOffset +import kotlin.test.assertEquals + +class TimeHelpersTest { + @Test + fun `parseISODate parses an offset ISO timestamp to UTC`() { + val result = parseISODate("2023-12-25T09:00:00-05:00") + + val expected = OffsetDateTime.of( + 2023, + 12, + 25, + 14, + 0, + 0, + 0, + ZoneOffset.UTC + ) + + assertEquals(expected = expected, actual = result) + } + + @Test + fun `parseISODate discards non-ISO formatted strings`() { + val result = parseISODate("Mon, 25 Dec 2023 17:18:03 +0000") + + assertEquals(expected = null, actual = result) + } + + @Test + fun `parseISODate discards null values`() { + val result = parseISODate(null) + + assertEquals(expected = null, actual = result) + } +}