From 838e8a663785dbb654d33c88af60a6ea086aaffd Mon Sep 17 00:00:00 2001 From: Josiah Campbell <9521010+jocmp@users.noreply.github.com> Date: Fri, 13 Feb 2026 08:56:44 -0600 Subject: [PATCH] wip: Apply reading time everywhere --- .../app/preferences/AppPreferences.kt | 3 + .../capyreader/app/sync/ReadingTimeWorker.kt | 41 +++++++++ .../com/capyreader/app/sync/SyncModule.kt | 1 + .../capyreader/app/ui/articles/ArticleList.kt | 3 + .../capyreader/app/ui/articles/ArticleRow.kt | 18 ++++ .../ui/articles/detail/ArticleBylineExt.kt | 17 +++- .../capyreader/app/ui/components/WebView.kt | 10 ++- .../app/ui/settings/SettingsModule.kt | 1 + .../ui/settings/panels/ArticleListSettings.kt | 9 ++ .../settings/panels/DisplaySettingsPanel.kt | 4 + .../panels/DisplaySettingsViewModel.kt | 33 +++++++ app/src/main/res/values/strings.xml | 7 ++ capy/src/main/java/com/jocmp/capy/Account.kt | 6 +- .../java/com/jocmp/capy/AccountPreferences.kt | 3 + capy/src/main/java/com/jocmp/capy/Article.kt | 1 + .../feedbin/FeedbinAccountDelegate.kt | 12 ++- .../accounts/local/LocalAccountDelegate.kt | 8 ++ .../miniflux/MinifluxAccountDelegate.kt | 11 ++- .../accounts/reader/BuildReaderDelegate.kt | 3 +- .../accounts/reader/ReaderAccountDelegate.kt | 14 ++- .../com/jocmp/capy/articles/ReadingTime.kt | 51 +++++++++++ .../jocmp/capy/persistence/ArticleMapper.kt | 4 + .../capy/db/21_AddReadingTimeToArticles.sqm | 1 + .../sqldelight/com/jocmp/capy/db/articles.sq | 15 +++- .../com/jocmp/capy/db/articlesByFeed.sq | 1 + .../jocmp/capy/db/articlesBySavedSearch.sq | 1 + .../com/jocmp/capy/db/articlesByStatus.sq | 1 + .../feedbin/FeedbinAccountDelegateTest.kt | 6 +- .../local/LocalAccountDelegateTest.kt | 3 + .../miniflux/MinifluxAccountDelegateTest.kt | 4 +- .../reader/ReaderAccountDelegateTest.kt | 6 +- .../jocmp/capy/articles/ReadingTimeTest.kt | 88 +++++++++++++++++++ .../com/jocmp/capy/fixtures/ArticleFixture.kt | 3 +- .../capy/persistence/ArticleMapperTest.kt | 1 + 34 files changed, 373 insertions(+), 17 deletions(-) create mode 100644 app/src/main/java/com/capyreader/app/sync/ReadingTimeWorker.kt create mode 100644 capy/src/main/java/com/jocmp/capy/articles/ReadingTime.kt create mode 100644 capy/src/main/sqldelight/com/jocmp/capy/db/21_AddReadingTimeToArticles.sqm create mode 100644 capy/src/test/java/com/jocmp/capy/articles/ReadingTimeTest.kt diff --git a/app/src/main/java/com/capyreader/app/preferences/AppPreferences.kt b/app/src/main/java/com/capyreader/app/preferences/AppPreferences.kt index 9d971b8c4..422ed3651 100644 --- a/app/src/main/java/com/capyreader/app/preferences/AppPreferences.kt +++ b/app/src/main/java/com/capyreader/app/preferences/AppPreferences.kt @@ -188,6 +188,9 @@ class AppPreferences(context: Context) { val confirmMarkAllRead: Preference get() = preferenceStore.getBoolean("article_list_confirm_mark_all_read", true) + val showReadingTime: Preference + get() = preferenceStore.getBoolean("article_list_show_reading_time", false) + val markReadOnScroll: Preference get() = preferenceStore.getBoolean("article_list_mark_read_on_scroll", false) diff --git a/app/src/main/java/com/capyreader/app/sync/ReadingTimeWorker.kt b/app/src/main/java/com/capyreader/app/sync/ReadingTimeWorker.kt new file mode 100644 index 000000000..c91f6af8b --- /dev/null +++ b/app/src/main/java/com/capyreader/app/sync/ReadingTimeWorker.kt @@ -0,0 +1,41 @@ +package com.capyreader.app.sync + +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import com.jocmp.capy.Account +import com.jocmp.capy.articles.ReadingTime +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +class ReadingTimeWorker( + appContext: Context, + workerParams: WorkerParameters +) : CoroutineWorker(appContext, workerParams), KoinComponent { + private val account by inject() + + override suspend fun doWork(): Result { + val batchSize = 500L + var processed: Int + + do { + val articles = account.database.articlesQueries + .articlesWithMissingReadingTime(batchSize) + .executeAsList() + processed = articles.size + + articles.forEach { row -> + val minutes = ReadingTime.calculate(row.content_html) + if (minutes != null) { + account.database.articlesQueries + .updateReadingTime( + readingTimeMinutes = minutes, + articleID = row.id + ) + } + } + } while (processed >= batchSize.toInt()) + + return Result.success() + } +} diff --git a/app/src/main/java/com/capyreader/app/sync/SyncModule.kt b/app/src/main/java/com/capyreader/app/sync/SyncModule.kt index b6f1d75d9..74f53a967 100644 --- a/app/src/main/java/com/capyreader/app/sync/SyncModule.kt +++ b/app/src/main/java/com/capyreader/app/sync/SyncModule.kt @@ -6,4 +6,5 @@ import org.koin.dsl.module val syncModule = module { worker { ReadSyncWorker(get(), get()) } worker { StarSyncWorker(get(), get()) } + worker { ReadingTimeWorker(get(), get()) } } diff --git a/app/src/main/java/com/capyreader/app/ui/articles/ArticleList.kt b/app/src/main/java/com/capyreader/app/ui/articles/ArticleList.kt index 96c664fd3..0672bff05 100644 --- a/app/src/main/java/com/capyreader/app/ui/articles/ArticleList.kt +++ b/app/src/main/java/com/capyreader/app/ui/articles/ArticleList.kt @@ -187,11 +187,14 @@ fun rememberArticleOptions(appPreferences: AppPreferences = koinInject()): Artic val fontScale by appPreferences.articleListOptions.fontScale.stateIn(scope).collectAsState() val shortenTitles by appPreferences.articleListOptions.shortenTitles.stateIn(scope) .collectAsState() + val showReadingTime by appPreferences.articleListOptions.showReadingTime.stateIn(scope) + .collectAsState() return ArticleRowOptions( showSummary = showSummary, showIcon = showIcon, showFeedName = showFeedName, + showReadingTime = showReadingTime, imagePreview = imagePreview, fontScale = fontScale, shortenTitles = shortenTitles, diff --git a/app/src/main/java/com/capyreader/app/ui/articles/ArticleRow.kt b/app/src/main/java/com/capyreader/app/ui/articles/ArticleRow.kt index a65d4f9c3..cfe3c2fc3 100644 --- a/app/src/main/java/com/capyreader/app/ui/articles/ArticleRow.kt +++ b/app/src/main/java/com/capyreader/app/ui/articles/ArticleRow.kt @@ -39,6 +39,7 @@ import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextDirection @@ -72,6 +73,7 @@ data class ArticleRowOptions( val showIcon: Boolean = true, val showSummary: Boolean = true, val showFeedName: Boolean = true, + val showReadingTime: Boolean = false, val imagePreview: ImagePreview = ImagePreview.default, val fontScale: ArticleListFontScale = ArticleListFontScale.MEDIUM, val shortenTitles: Boolean = true, @@ -165,6 +167,22 @@ fun ArticleRow( .padding(end = 2.dp) ) } + val readingTimeMinutes = article.readingTimeMinutes + if (options.showReadingTime && readingTimeMinutes != null) { + Text( + text = pluralStringResource( + R.plurals.reading_time_minutes, + readingTimeMinutes.toInt(), + readingTimeMinutes.toInt() + ), + color = feedNameColor, + maxLines = 1, + ) + Text( + text = "·", + color = feedNameColor, + ) + } Text( text = relativeTime( time = article.publishedAt, diff --git a/app/src/main/java/com/capyreader/app/ui/articles/detail/ArticleBylineExt.kt b/app/src/main/java/com/capyreader/app/ui/articles/detail/ArticleBylineExt.kt index 532194e9e..6120d09b9 100644 --- a/app/src/main/java/com/capyreader/app/ui/articles/detail/ArticleBylineExt.kt +++ b/app/src/main/java/com/capyreader/app/ui/articles/detail/ArticleBylineExt.kt @@ -7,10 +7,25 @@ import com.jocmp.capy.common.toDeviceDateTime import java.time.format.DateTimeFormatter import java.time.format.FormatStyle -fun Article.byline(context: Context): String { +fun Article.byline(context: Context, showReadingTime: Boolean = false): String { val deviceDateTime = publishedAt.toDeviceDateTime() val date = DateTimeFormatter.ofLocalizedDate(FormatStyle.LONG).format(deviceDateTime) val time = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).format(deviceDateTime) + val minutes = readingTimeMinutes + + if (showReadingTime && minutes != null) { + val readingTimeText = context.resources.getQuantityString( + R.plurals.reading_time_minutes, + minutes.toInt(), + minutes.toInt() + ) + + return if (!author.isNullOrBlank()) { + context.getString(R.string.article_byline_with_reading_time, date, time, author, readingTimeText) + } else { + context.getString(R.string.article_byline_date_only_with_reading_time, date, time, readingTimeText) + } + } return if (!author.isNullOrBlank()) { context.getString(R.string.article_byline, date, time, author) diff --git a/app/src/main/java/com/capyreader/app/ui/components/WebView.kt b/app/src/main/java/com/capyreader/app/ui/components/WebView.kt index 200246bdc..9919118d8 100644 --- a/app/src/main/java/com/capyreader/app/ui/components/WebView.kt +++ b/app/src/main/java/com/capyreader/app/ui/components/WebView.kt @@ -23,6 +23,7 @@ import com.capyreader.app.common.AudioEnclosure import com.capyreader.app.common.Media import com.capyreader.app.common.WebViewInterface import com.capyreader.app.common.rememberTalkbackPreference +import com.capyreader.app.preferences.AppPreferences import com.capyreader.app.ui.articles.detail.articleTemplateColors import com.capyreader.app.ui.articles.detail.byline import com.jocmp.capy.Article @@ -158,6 +159,7 @@ class WebViewState( private val renderer: ArticleRenderer, private val colors: Map, private val enableNativeScroll: Boolean, + private val showReadingTime: Boolean, internal val webView: WebView, ) { private var htmlId: String? = null @@ -184,7 +186,10 @@ class WebViewState( val html = renderer.render( article, hideImages = !showImages, - byline = article.byline(context = webView.context), + byline = article.byline( + context = webView.context, + showReadingTime = showReadingTime + ), colors = colors ) @@ -239,7 +244,9 @@ fun rememberWebViewState( isAudioPlaying: Boolean = false, key: String? = null, ): WebViewState { + val appPreferences: AppPreferences = koinInject() val enableNativeScroll by rememberTalkbackPreference() + val showReadingTime = appPreferences.articleListOptions.showReadingTime.get() val colors = articleTemplateColors() val context = LocalContext.current val currentAudioUrlState by rememberUpdatedState(currentAudioUrl) @@ -301,6 +308,7 @@ fun rememberWebViewState( renderer, colors, enableNativeScroll = enableNativeScroll, + showReadingTime = showReadingTime, webView, ).also { client.state = it diff --git a/app/src/main/java/com/capyreader/app/ui/settings/SettingsModule.kt b/app/src/main/java/com/capyreader/app/ui/settings/SettingsModule.kt index f3508b4d9..4a521b0f5 100644 --- a/app/src/main/java/com/capyreader/app/ui/settings/SettingsModule.kt +++ b/app/src/main/java/com/capyreader/app/ui/settings/SettingsModule.kt @@ -28,6 +28,7 @@ val settingsModule = module { } viewModel { DisplaySettingsViewModel( + context = get(), account = get(), appPreferences = get(), ) diff --git a/app/src/main/java/com/capyreader/app/ui/settings/panels/ArticleListSettings.kt b/app/src/main/java/com/capyreader/app/ui/settings/panels/ArticleListSettings.kt index 89224ebd0..6372cc94e 100644 --- a/app/src/main/java/com/capyreader/app/ui/settings/panels/ArticleListSettings.kt +++ b/app/src/main/java/com/capyreader/app/ui/settings/panels/ArticleListSettings.kt @@ -25,12 +25,14 @@ data class ArticleListOptions( val showFeedIcons: Boolean, val showFeedName: Boolean, val showSummary: Boolean, + val showReadingTime: Boolean, val shortenTitles: Boolean, val fontScale: ArticleListFontScale, val updateFeedIcons: (show: Boolean) -> Unit, val updateFeedName: (show: Boolean) -> Unit, val updateImagePreview: (preview: ImagePreview) -> Unit, val updateSummary: (show: Boolean) -> Unit, + val updateShowReadingTime: (show: Boolean) -> Unit, val updateFontScale: (scale: ArticleListFontScale) -> Unit, val updateShortenTitles: (show: Boolean) -> Unit, ) @@ -63,6 +65,11 @@ fun ArticleListSettings( checked = options.shortenTitles, title = stringResource(R.string.settings_article_list_shorten_titles) ) + TextSwitch( + onCheckedChange = options.updateShowReadingTime, + checked = options.showReadingTime, + title = stringResource(R.string.settings_article_list_show_reading_time) + ) } PreferenceSelect( @@ -103,11 +110,13 @@ private fun ArticleListSettingsPreview() { imagePreview = ImagePreview.default, showSummary = true, showFeedIcons = true, + showReadingTime = false, fontScale = ArticleListFontScale.LARGE, showFeedName = false, shortenTitles = true, updateImagePreview = {}, updateSummary = {}, + updateShowReadingTime = {}, updateFeedName = {}, updateFeedIcons = {}, updateFontScale = {}, diff --git a/app/src/main/java/com/capyreader/app/ui/settings/panels/DisplaySettingsPanel.kt b/app/src/main/java/com/capyreader/app/ui/settings/panels/DisplaySettingsPanel.kt index a6d5a9404..31cb9f842 100644 --- a/app/src/main/java/com/capyreader/app/ui/settings/panels/DisplaySettingsPanel.kt +++ b/app/src/main/java/com/capyreader/app/ui/settings/panels/DisplaySettingsPanel.kt @@ -68,12 +68,14 @@ fun DisplaySettingsPanel( articleListOptions = ArticleListOptions( imagePreview = viewModel.imagePreview, showSummary = viewModel.showSummary, + showReadingTime = viewModel.showReadingTime, fontScale = viewModel.fontScale, showFeedIcons = viewModel.showFeedIcons, showFeedName = viewModel.showFeedName, shortenTitles = viewModel.shortenTitles, updateImagePreview = viewModel::updateImagePreview, updateSummary = viewModel::updateSummary, + updateShowReadingTime = viewModel::updateShowReadingTime, updateFeedName = viewModel::updateFeedName, updateFeedIcons = viewModel::updateFeedIcons, updateFontScale = viewModel::updateFontScale, @@ -235,12 +237,14 @@ private fun DisplaySettingsPanelViewPreview() { articleListOptions = ArticleListOptions( imagePreview = ImagePreview.default, showSummary = true, + showReadingTime = false, fontScale = ArticleListFontScale.MEDIUM, showFeedIcons = true, showFeedName = false, shortenTitles = true, updateImagePreview = {}, updateSummary = {}, + updateShowReadingTime = {}, updateFeedName = {}, updateFeedIcons = {}, updateFontScale = {}, diff --git a/app/src/main/java/com/capyreader/app/ui/settings/panels/DisplaySettingsViewModel.kt b/app/src/main/java/com/capyreader/app/ui/settings/panels/DisplaySettingsViewModel.kt index b3b961708..024c81029 100644 --- a/app/src/main/java/com/capyreader/app/ui/settings/panels/DisplaySettingsViewModel.kt +++ b/app/src/main/java/com/capyreader/app/ui/settings/panels/DisplaySettingsViewModel.kt @@ -1,20 +1,26 @@ package com.capyreader.app.ui.settings.panels +import android.content.Context import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager import com.capyreader.app.common.ImagePreview import com.capyreader.app.preferences.AppPreferences import com.capyreader.app.preferences.LayoutPreference import com.capyreader.app.preferences.ReaderImageVisibility import com.capyreader.app.preferences.ThemeMode import com.capyreader.app.preferences.AppTheme +import com.capyreader.app.sync.ReadingTimeWorker import com.capyreader.app.ui.articles.ArticleListFontScale import com.capyreader.app.ui.articles.MarkReadPosition import com.jocmp.capy.Account class DisplaySettingsViewModel( + private val context: Context, val account: Account, val appPreferences: AppPreferences, ) : ViewModel() { @@ -38,6 +44,12 @@ class DisplaySettingsViewModel( private val _shortenTitles = mutableStateOf(appPreferences.articleListOptions.shortenTitles.get()) + private val _showReadingTime = + mutableStateOf(appPreferences.articleListOptions.showReadingTime.get()) + + val showReadingTime: Boolean + get() = _showReadingTime.value + var fontScale by mutableStateOf(appPreferences.articleListOptions.fontScale.get()) private set @@ -138,4 +150,25 @@ class DisplaySettingsViewModel( _shortenTitles.value = shortenTitles } + + fun updateShowReadingTime(show: Boolean) { + appPreferences.articleListOptions.showReadingTime.set(show) + account.preferences.showReadingTime.set(show) + + _showReadingTime.value = show + + if (show) { + backfillReadingTime() + } + } + + private fun backfillReadingTime() { + val request = OneTimeWorkRequestBuilder().build() + WorkManager.getInstance(context) + .enqueueUniqueWork(READING_TIME_WORK, ExistingWorkPolicy.REPLACE, request) + } + + companion object { + private const val READING_TIME_WORK = "reading_time_backfill" + } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e6eab31ad..8e517ba6e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -162,6 +162,7 @@ Feed Icons Summary Shorten titles + Reading time Version Copy version Sticky Full Content @@ -189,6 +190,12 @@ Error loading image %1$s at %2$s by %3$s %1$s at %2$s + %1$s at %2$s by %3$s · %4$s + %1$s at %2$s · %3$s + + %d min + %d min + Delete read, unstarred articles older than 3 months Auto Delete Articles Every day diff --git a/capy/src/main/java/com/jocmp/capy/Account.kt b/capy/src/main/java/com/jocmp/capy/Account.kt index 1608afa02..072c84690 100644 --- a/capy/src/main/java/com/jocmp/capy/Account.kt +++ b/capy/src/main/java/com/jocmp/capy/Account.kt @@ -72,7 +72,8 @@ data class Account( feedbin = Feedbin.forAccount( path = cacheDirectory, preferences = preferences - ) + ), + preferences = preferences, ) Source.MINIFLUX, @@ -82,7 +83,8 @@ data class Account( path = cacheDirectory, preferences = preferences, source = source - ) + ), + preferences = preferences, ) Source.FRESHRSS, diff --git a/capy/src/main/java/com/jocmp/capy/AccountPreferences.kt b/capy/src/main/java/com/jocmp/capy/AccountPreferences.kt index 2d2d65cf2..742082836 100644 --- a/capy/src/main/java/com/jocmp/capy/AccountPreferences.kt +++ b/capy/src/main/java/com/jocmp/capy/AccountPreferences.kt @@ -29,4 +29,7 @@ class AccountPreferences( val keywordBlocklist: Preference> get() = store.getStringSet("keyword_blocklist") + + val showReadingTime: Preference + get() = store.getBoolean("show_reading_time", false) } diff --git a/capy/src/main/java/com/jocmp/capy/Article.kt b/capy/src/main/java/com/jocmp/capy/Article.kt index 17d918f09..4250717fd 100644 --- a/capy/src/main/java/com/jocmp/capy/Article.kt +++ b/capy/src/main/java/com/jocmp/capy/Article.kt @@ -24,6 +24,7 @@ data class Article( val openInBrowser: Boolean = false, val fullContent: FullContentState = FullContentState.NONE, val content: String = contentHTML.ifBlank { summary }, + val readingTimeMinutes: Long? = null, val enclosures: List = emptyList(), val enclosureType: EnclosureType? = null, ) { diff --git a/capy/src/main/java/com/jocmp/capy/accounts/feedbin/FeedbinAccountDelegate.kt b/capy/src/main/java/com/jocmp/capy/accounts/feedbin/FeedbinAccountDelegate.kt index 938eeb717..3b2676d80 100644 --- a/capy/src/main/java/com/jocmp/capy/accounts/feedbin/FeedbinAccountDelegate.kt +++ b/capy/src/main/java/com/jocmp/capy/accounts/feedbin/FeedbinAccountDelegate.kt @@ -1,12 +1,14 @@ package com.jocmp.capy.accounts.feedbin import com.jocmp.capy.AccountDelegate +import com.jocmp.capy.AccountPreferences import com.jocmp.capy.ArticleFilter import com.jocmp.capy.Feed import com.jocmp.capy.accounts.AddFeedResult import com.jocmp.capy.accounts.FeedOption import com.jocmp.capy.accounts.SubscriptionChoice import com.jocmp.capy.accounts.withErrorHandling +import com.jocmp.capy.articles.ReadingTime import com.jocmp.capy.common.TimeHelpers import com.jocmp.capy.common.UnauthorizedError import com.jocmp.capy.common.host @@ -42,7 +44,8 @@ import java.time.ZonedDateTime internal class FeedbinAccountDelegate( private val database: Database, - private val feedbin: Feedbin + private val feedbin: Feedbin, + private val preferences: AccountPreferences, ) : AccountDelegate { private val articleRecords = ArticleRecords(database) private val enclosureRecords = EnclosureRecords(database) @@ -411,6 +414,7 @@ internal class FeedbinAccountDelegate( image_url = entry.images?.size_1?.cdn_url, published_at = entry.published.toDateTime?.toEpochSecond(), enclosure_type = enclosureType, + reading_time_minutes = readingTime(entry.content), ) articleRecords.createStatus( @@ -447,6 +451,12 @@ internal class FeedbinAccountDelegate( ) } + private fun readingTime(content: String?): Long? { + if (!preferences.showReadingTime.get()) return null + + return ReadingTime.calculate(content) + } + private fun maxArrivedAt() = articleRecords.maxArrivedAt().toString() private suspend fun fetchIcons(): List { diff --git a/capy/src/main/java/com/jocmp/capy/accounts/local/LocalAccountDelegate.kt b/capy/src/main/java/com/jocmp/capy/accounts/local/LocalAccountDelegate.kt index 5b53a9749..94662e37b 100644 --- a/capy/src/main/java/com/jocmp/capy/accounts/local/LocalAccountDelegate.kt +++ b/capy/src/main/java/com/jocmp/capy/accounts/local/LocalAccountDelegate.kt @@ -6,6 +6,7 @@ import com.jocmp.capy.ArticleFilter import com.jocmp.capy.Feed import com.jocmp.capy.accounts.AddFeedResult import com.jocmp.capy.accounts.FeedOption +import com.jocmp.capy.articles.ReadingTime import com.jocmp.capy.common.ContentFormatter import com.jocmp.capy.common.TimeHelpers.nowUTC import com.jocmp.capy.common.TimeHelpers.published @@ -255,6 +256,7 @@ internal class LocalAccountDelegate( image_url = parsedItem.imageURL, published_at = publishedAt, enclosure_type = enclosureType, + reading_time_minutes = readingTime(parsedItem.contentHTML), ) articleRecords.createStatus( @@ -322,6 +324,12 @@ internal class LocalAccountDelegate( } } + private fun readingTime(content: String?): Long? { + if (!preferences.showReadingTime.get()) return null + + return ReadingTime.calculate(content) + } + companion object { private fun tag(path: String) = "$TAG.$path" diff --git a/capy/src/main/java/com/jocmp/capy/accounts/miniflux/MinifluxAccountDelegate.kt b/capy/src/main/java/com/jocmp/capy/accounts/miniflux/MinifluxAccountDelegate.kt index 8459492c6..c7d05a055 100644 --- a/capy/src/main/java/com/jocmp/capy/accounts/miniflux/MinifluxAccountDelegate.kt +++ b/capy/src/main/java/com/jocmp/capy/accounts/miniflux/MinifluxAccountDelegate.kt @@ -1,6 +1,7 @@ package com.jocmp.capy.accounts.miniflux import com.jocmp.capy.AccountDelegate +import com.jocmp.capy.AccountPreferences import com.jocmp.capy.ArticleFilter import com.jocmp.capy.Feed import com.jocmp.capy.accounts.AddFeedResult @@ -37,7 +38,8 @@ import com.jocmp.minifluxclient.Feed as MinifluxFeed internal class MinifluxAccountDelegate( private val database: Database, - private val miniflux: Miniflux + private val miniflux: Miniflux, + private val preferences: AccountPreferences, ) : AccountDelegate { private val articleRecords = ArticleRecords(database) private val enclosureRecords = EnclosureRecords(database) @@ -338,6 +340,7 @@ internal class MinifluxAccountDelegate( image_url = imageURL, published_at = entry.published_at.toDateTime?.toEpochSecond(), enclosure_type = enclosures.firstOrNull()?.mime_type, + reading_time_minutes = readingTime(entry), ) articleRecords.createStatus( @@ -359,6 +362,12 @@ internal class MinifluxAccountDelegate( } } + private fun readingTime(entry: Entry): Long? { + if (!preferences.showReadingTime.get()) return null + + return entry.reading_time.toLong() + } + private fun upsertFeed(feed: MinifluxFeed, icons: Map) { val icon = feed.icon?.icon_id?.let { icons[it] } diff --git a/capy/src/main/java/com/jocmp/capy/accounts/reader/BuildReaderDelegate.kt b/capy/src/main/java/com/jocmp/capy/accounts/reader/BuildReaderDelegate.kt index 6227b6bb6..abe1afbbc 100644 --- a/capy/src/main/java/com/jocmp/capy/accounts/reader/BuildReaderDelegate.kt +++ b/capy/src/main/java/com/jocmp/capy/accounts/reader/BuildReaderDelegate.kt @@ -23,6 +23,7 @@ internal fun buildReaderDelegate( googleReader = GoogleReader.create( client = httpClient, baseURL = preferences.url.get() - ) + ), + preferences = preferences, ) } diff --git a/capy/src/main/java/com/jocmp/capy/accounts/reader/ReaderAccountDelegate.kt b/capy/src/main/java/com/jocmp/capy/accounts/reader/ReaderAccountDelegate.kt index aaa47135b..d9b5d7cf8 100644 --- a/capy/src/main/java/com/jocmp/capy/accounts/reader/ReaderAccountDelegate.kt +++ b/capy/src/main/java/com/jocmp/capy/accounts/reader/ReaderAccountDelegate.kt @@ -1,6 +1,7 @@ package com.jocmp.capy.accounts.reader import com.jocmp.capy.AccountDelegate +import com.jocmp.capy.AccountPreferences import com.jocmp.capy.ArticleFilter import com.jocmp.capy.Feed import com.jocmp.capy.accounts.AddFeedResult @@ -8,6 +9,7 @@ import com.jocmp.capy.accounts.Source import com.jocmp.capy.accounts.ValidationError import com.jocmp.capy.accounts.feedbin.FeedbinAccountDelegate.Companion.MAX_CREATE_UNREAD_LIMIT import com.jocmp.capy.accounts.withErrorHandling +import com.jocmp.capy.articles.ReadingTime import com.jocmp.capy.common.TimeHelpers import com.jocmp.capy.common.launchIO import com.jocmp.capy.common.transactionWithErrorHandling @@ -46,6 +48,7 @@ internal class ReaderAccountDelegate( private val source: Source, private val database: Database, private val googleReader: GoogleReader, + private val preferences: AccountPreferences, ) : AccountDelegate { private var postToken = AtomicReference(null) private val articleRecords = ArticleRecords(database) @@ -468,18 +471,21 @@ internal class ReaderAccountDelegate( val enclosures = ReaderEnclosureParsing.validEnclosures(item) val enclosureType = enclosures.firstOrNull()?.type + val contentHtml = item.content?.content ?: item.summary.content + database.articlesQueries.create( id = item.hexID, feed_id = item.origin.streamId, title = item.title, author = item.author, - content_html = item.content?.content ?: item.summary.content, + content_html = contentHtml, extracted_content_url = null, summary = item.summary.content?.let { Jsoup.parse(it).text() }, url = item.canonical.firstOrNull()?.href, image_url = ReaderEnclosureParsing.parsedImageURL(item), published_at = item.published, enclosure_type = enclosureType, + reading_time_minutes = readingTime(contentHtml), ) articleRecords.updateStatus( @@ -575,6 +581,12 @@ internal class ReaderAccountDelegate( return "${subscription.id}:${category.id}" } + private fun readingTime(contentHtml: String?): Long? { + if (!preferences.showReadingTime.get()) return null + + return ReadingTime.calculate(contentHtml) + } + companion object { const val MAX_PAGINATED_ITEM_LIMIT = 100 diff --git a/capy/src/main/java/com/jocmp/capy/articles/ReadingTime.kt b/capy/src/main/java/com/jocmp/capy/articles/ReadingTime.kt new file mode 100644 index 000000000..c6bbffa84 --- /dev/null +++ b/capy/src/main/java/com/jocmp/capy/articles/ReadingTime.kt @@ -0,0 +1,51 @@ +package com.jocmp.capy.articles + +import org.jsoup.Jsoup + +object ReadingTime { + private const val CJK_CHARACTERS_PER_MINUTE = 265 + private const val CHARACTERS_PER_MINUTE = 500 + + fun calculate(contentHtml: String?): Long? { + if (contentHtml.isNullOrBlank()) return null + + val text = Jsoup.parse(contentHtml).text() + + if (text.isBlank()) return null + + val cpm = if (isCJK(text)) CJK_CHARACTERS_PER_MINUTE else CHARACTERS_PER_MINUTE + val minutes = (text.length + cpm - 1) / cpm + + return if (minutes > 0) minutes.toLong() else null + } + + internal fun isCJK(text: String): Boolean { + var totalCJK = 0 + var totalChecked = 0 + + for (c in text) { + if (totalChecked >= 50) break + totalChecked++ + val block = Character.UnicodeBlock.of(c) + if (block in CJK_BLOCKS) totalCJK++ + } + + return totalChecked > 0 && totalCJK * 2 >= totalChecked + } + + private val CJK_BLOCKS = setOf( + Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS, + Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_A, + Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_B, + Character.UnicodeBlock.CJK_COMPATIBILITY_IDEOGRAPHS, + Character.UnicodeBlock.CJK_COMPATIBILITY_FORMS, + Character.UnicodeBlock.HIRAGANA, + Character.UnicodeBlock.KATAKANA, + Character.UnicodeBlock.HANGUL_SYLLABLES, + Character.UnicodeBlock.HANGUL_JAMO, + Character.UnicodeBlock.HANGUL_COMPATIBILITY_JAMO, + Character.UnicodeBlock.BOPOMOFO, + Character.UnicodeBlock.YI_SYLLABLES, + Character.UnicodeBlock.YI_RADICALS, + ) +} diff --git a/capy/src/main/java/com/jocmp/capy/persistence/ArticleMapper.kt b/capy/src/main/java/com/jocmp/capy/persistence/ArticleMapper.kt index e39721a6e..1d9992e10 100644 --- a/capy/src/main/java/com/jocmp/capy/persistence/ArticleMapper.kt +++ b/capy/src/main/java/com/jocmp/capy/persistence/ArticleMapper.kt @@ -17,6 +17,7 @@ internal fun articleMapper( imageURL: String?, publishedAt: Long?, enclosureType: String?, + readingTimeMinutes: Long?, feedTitle: String?, faviconURL: String?, enableStickyContent: Boolean, @@ -46,6 +47,7 @@ internal fun articleMapper( feedName = feedTitle ?: "", enableStickyFullContent = enableStickyContent, openInBrowser = openInBrowser, + readingTimeMinutes = readingTimeMinutes, enclosureType = EnclosureType.from(enclosureType), ) } @@ -60,6 +62,7 @@ internal fun listMapper( imageURL: String?, publishedAt: Long?, enclosureType: String?, + readingTimeMinutes: Long?, feedTitle: String?, faviconURL: String?, openInBrowser: Boolean, @@ -85,6 +88,7 @@ internal fun listMapper( imageURL = imageURL, publishedAt = publishedAt, enclosureType = enclosureType, + readingTimeMinutes = readingTimeMinutes, feedTitle = feedTitle, faviconURL = faviconURL, enableStickyContent = false, diff --git a/capy/src/main/sqldelight/com/jocmp/capy/db/21_AddReadingTimeToArticles.sqm b/capy/src/main/sqldelight/com/jocmp/capy/db/21_AddReadingTimeToArticles.sqm new file mode 100644 index 000000000..a55d8579a --- /dev/null +++ b/capy/src/main/sqldelight/com/jocmp/capy/db/21_AddReadingTimeToArticles.sqm @@ -0,0 +1 @@ +ALTER TABLE articles ADD COLUMN reading_time_minutes INTEGER; diff --git a/capy/src/main/sqldelight/com/jocmp/capy/db/articles.sq b/capy/src/main/sqldelight/com/jocmp/capy/db/articles.sq index 666d6bd60..38d576334 100644 --- a/capy/src/main/sqldelight/com/jocmp/capy/db/articles.sq +++ b/capy/src/main/sqldelight/com/jocmp/capy/db/articles.sq @@ -65,7 +65,8 @@ INSERT INTO articles( summary, image_url, published_at, - enclosure_type + enclosure_type, + reading_time_minutes ) VALUES ( :id, @@ -78,7 +79,8 @@ VALUES ( :summary, :image_url, :published_at, -:enclosure_type +:enclosure_type, +:reading_time_minutes ) ON CONFLICT(id) DO UPDATE SET @@ -92,7 +94,8 @@ url = excluded.url, summary = excluded.summary, image_url = excluded.image_url, published_at = published_at, -enclosure_type = excluded.enclosure_type; +enclosure_type = excluded.enclosure_type, +reading_time_minutes = excluded.reading_time_minutes; createStatus: INSERT INTO article_statuses( @@ -261,3 +264,9 @@ WHERE starred = 0 deleteByID: DELETE FROM articles WHERE id = :articleID; + +updateReadingTime: +UPDATE articles SET reading_time_minutes = :readingTimeMinutes WHERE id = :articleID; + +articlesWithMissingReadingTime: +SELECT id, content_html FROM articles WHERE reading_time_minutes IS NULL LIMIT :limit; diff --git a/capy/src/main/sqldelight/com/jocmp/capy/db/articlesByFeed.sq b/capy/src/main/sqldelight/com/jocmp/capy/db/articlesByFeed.sq index 049276ec8..05a6b1fe2 100644 --- a/capy/src/main/sqldelight/com/jocmp/capy/db/articlesByFeed.sq +++ b/capy/src/main/sqldelight/com/jocmp/capy/db/articlesByFeed.sq @@ -9,6 +9,7 @@ SELECT articles.image_url, articles.published_at, articles.enclosure_type, + articles.reading_time_minutes, feeds.title AS feed_title, feeds.favicon_url, feeds.open_articles_in_browser, diff --git a/capy/src/main/sqldelight/com/jocmp/capy/db/articlesBySavedSearch.sq b/capy/src/main/sqldelight/com/jocmp/capy/db/articlesBySavedSearch.sq index 09e631c17..f2c0da0fd 100644 --- a/capy/src/main/sqldelight/com/jocmp/capy/db/articlesBySavedSearch.sq +++ b/capy/src/main/sqldelight/com/jocmp/capy/db/articlesBySavedSearch.sq @@ -9,6 +9,7 @@ SELECT articles.image_url, articles.published_at, articles.enclosure_type, + articles.reading_time_minutes, feeds.title AS feed_title, feeds.favicon_url, feeds.open_articles_in_browser, diff --git a/capy/src/main/sqldelight/com/jocmp/capy/db/articlesByStatus.sq b/capy/src/main/sqldelight/com/jocmp/capy/db/articlesByStatus.sq index 840b2f6e7..fda5de03f 100644 --- a/capy/src/main/sqldelight/com/jocmp/capy/db/articlesByStatus.sq +++ b/capy/src/main/sqldelight/com/jocmp/capy/db/articlesByStatus.sq @@ -9,6 +9,7 @@ SELECT articles.image_url, articles.published_at, articles.enclosure_type, + articles.reading_time_minutes, feeds.title AS feed_title, feeds.favicon_url, feeds.open_articles_in_browser, diff --git a/capy/src/test/java/com/jocmp/capy/accounts/feedbin/FeedbinAccountDelegateTest.kt b/capy/src/test/java/com/jocmp/capy/accounts/feedbin/FeedbinAccountDelegateTest.kt index 5f1b23994..29a8dd8cb 100644 --- a/capy/src/test/java/com/jocmp/capy/accounts/feedbin/FeedbinAccountDelegateTest.kt +++ b/capy/src/test/java/com/jocmp/capy/accounts/feedbin/FeedbinAccountDelegateTest.kt @@ -1,8 +1,10 @@ package com.jocmp.capy.accounts.feedbin import com.jocmp.capy.AccountDelegate +import com.jocmp.capy.AccountPreferences import com.jocmp.capy.ArticleFilter import com.jocmp.capy.ArticleStatus +import com.jocmp.capy.InMemoryDataStore import com.jocmp.capy.InMemoryDatabaseProvider import com.jocmp.capy.accounts.AddFeedResult import com.jocmp.capy.accounts.SubscriptionChoice @@ -127,7 +129,7 @@ class FeedbinAccountDelegateTest { database = InMemoryDatabaseProvider.build(accountID) feedFixture = FeedFixture(database) feedbin = mockk() - delegate = FeedbinAccountDelegate(database, feedbin) + delegate = FeedbinAccountDelegate(database, feedbin, AccountPreferences(InMemoryDataStore())) coEvery { feedbin.icons() }.returns(Response.success(listOf())) } @@ -448,7 +450,7 @@ class FeedbinAccountDelegateTest { @Test fun updateFeed_modifyTitle() = runTest { - val delegate = FeedbinAccountDelegate(database, feedbin) + val delegate = FeedbinAccountDelegate(database, feedbin, AccountPreferences(InMemoryDataStore())) val feed = feedFixture.create() val subscription = Subscription( diff --git a/capy/src/test/java/com/jocmp/capy/accounts/local/LocalAccountDelegateTest.kt b/capy/src/test/java/com/jocmp/capy/accounts/local/LocalAccountDelegateTest.kt index ffca1ea12..63eb33c87 100644 --- a/capy/src/test/java/com/jocmp/capy/accounts/local/LocalAccountDelegateTest.kt +++ b/capy/src/test/java/com/jocmp/capy/accounts/local/LocalAccountDelegateTest.kt @@ -91,6 +91,9 @@ class LocalAccountDelegateTest { val blocklist = mockk>>() every { blocklist.get() }.returns(emptySet()) every { accountPreferences.keywordBlocklist }.returns(blocklist) + val showReadingTime = mockk>() + every { showReadingTime.get() }.returns(false) + every { accountPreferences.showReadingTime }.returns(showReadingTime) every { CapyLog.warn(any(), any()) }.returns(Unit) every { CapyLog.error(any(), any()) }.returns(Unit) every { CapyLog.info(any(), any()) }.returns(Unit) diff --git a/capy/src/test/java/com/jocmp/capy/accounts/miniflux/MinifluxAccountDelegateTest.kt b/capy/src/test/java/com/jocmp/capy/accounts/miniflux/MinifluxAccountDelegateTest.kt index 416b15cc9..f8c38b4ca 100644 --- a/capy/src/test/java/com/jocmp/capy/accounts/miniflux/MinifluxAccountDelegateTest.kt +++ b/capy/src/test/java/com/jocmp/capy/accounts/miniflux/MinifluxAccountDelegateTest.kt @@ -1,7 +1,9 @@ package com.jocmp.capy.accounts.miniflux import com.jocmp.capy.AccountDelegate +import com.jocmp.capy.AccountPreferences import com.jocmp.capy.ArticleFilter +import com.jocmp.capy.InMemoryDataStore import com.jocmp.capy.InMemoryDatabaseProvider import com.jocmp.capy.accounts.AddFeedResult import com.jocmp.capy.db.Database @@ -163,7 +165,7 @@ class MinifluxAccountDelegateTest { database = InMemoryDatabaseProvider.build(accountID) feedFixture = FeedFixture(database) miniflux = mockk() - delegate = MinifluxAccountDelegate(database, miniflux) + delegate = MinifluxAccountDelegate(database, miniflux, AccountPreferences(InMemoryDataStore())) } @Test diff --git a/capy/src/test/java/com/jocmp/capy/accounts/reader/ReaderAccountDelegateTest.kt b/capy/src/test/java/com/jocmp/capy/accounts/reader/ReaderAccountDelegateTest.kt index de8b7f12a..ed2cc3ec8 100644 --- a/capy/src/test/java/com/jocmp/capy/accounts/reader/ReaderAccountDelegateTest.kt +++ b/capy/src/test/java/com/jocmp/capy/accounts/reader/ReaderAccountDelegateTest.kt @@ -1,8 +1,10 @@ package com.jocmp.capy.accounts.reader import com.jocmp.capy.AccountDelegate +import com.jocmp.capy.AccountPreferences import com.jocmp.capy.ArticleFilter import com.jocmp.capy.ArticleStatus +import com.jocmp.capy.InMemoryDataStore import com.jocmp.capy.InMemoryDatabaseProvider import com.jocmp.capy.accounts.AddFeedResult import com.jocmp.capy.accounts.Source @@ -175,7 +177,7 @@ class ReaderAccountDelegateTest { folderFixture = FolderFixture(database) googleReader = mockk() - delegate = ReaderAccountDelegate(source = Source.FRESHRSS, database, googleReader) + delegate = ReaderAccountDelegate(source = Source.FRESHRSS, database, googleReader, AccountPreferences(InMemoryDataStore())) } @Test @@ -273,7 +275,7 @@ class ReaderAccountDelegateTest { @Test fun `refresh Miniflux folder`() = runTest { - delegate = ReaderAccountDelegate(source = Source.READER, database, googleReader) + delegate = ReaderAccountDelegate(source = Source.READER, database, googleReader, AccountPreferences(InMemoryDataStore())) val folderTitle = "Tech" val feed = feedFixture.create(feedID = "feed/2") diff --git a/capy/src/test/java/com/jocmp/capy/articles/ReadingTimeTest.kt b/capy/src/test/java/com/jocmp/capy/articles/ReadingTimeTest.kt new file mode 100644 index 000000000..cf98b2603 --- /dev/null +++ b/capy/src/test/java/com/jocmp/capy/articles/ReadingTimeTest.kt @@ -0,0 +1,88 @@ +package com.jocmp.capy.articles + +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.test.assertFalse + +class ReadingTimeTest { + @Test + fun `returns null for null content`() { + assertNull(ReadingTime.calculate(null)) + } + + @Test + fun `returns null for blank content`() { + assertNull(ReadingTime.calculate("")) + assertNull(ReadingTime.calculate(" ")) + } + + @Test + fun `returns null for HTML with no text`() { + assertNull(ReadingTime.calculate("
")) + } + + @Test + fun `calculates reading time for English content`() { + // 500 chars / 500 cpm = 1 min + val content = "

${"a".repeat(500)}

" + assertEquals(1L, ReadingTime.calculate(content)) + } + + @Test + fun `rounds up for partial minutes`() { + // 501 chars / 500 cpm = 1.002 -> 2 min + val content = "

${"a".repeat(501)}

" + assertEquals(2L, ReadingTime.calculate(content)) + } + + @Test + fun `calculates reading time for longer content`() { + // 2500 chars / 500 cpm = 5 min + val content = "

${"word ".repeat(500)}

" + val result = ReadingTime.calculate(content)!! + assertTrue(result > 0) + } + + @Test + fun `calculates reading time for CJK content`() { + // 265 CJK chars / 265 cpm = 1 min + val content = "

${"\u4e00".repeat(265)}

" + assertEquals(1L, ReadingTime.calculate(content)) + } + + @Test + fun `uses CJK rate when majority is CJK`() { + // 530 CJK chars / 265 cpm = 2 min + val content = "

${"\u4e00".repeat(530)}

" + assertEquals(2L, ReadingTime.calculate(content)) + } + + @Test + fun `isCJK detects Chinese text`() { + assertTrue(ReadingTime.isCJK("\u4e00\u4e01\u4e02")) + } + + @Test + fun `isCJK detects Japanese text`() { + assertTrue(ReadingTime.isCJK("\u3042\u3044\u3046")) // hiragana + } + + @Test + fun `isCJK detects Korean text`() { + assertTrue(ReadingTime.isCJK("\uAC00\uAC01\uAC02")) // hangul + } + + @Test + fun `isCJK returns false for Latin text`() { + assertFalse(ReadingTime.isCJK("Hello world")) + } + + @Test + fun `strips HTML tags before counting`() { + val content = "

Title

Body text

" + val result = ReadingTime.calculate(content)!! + assertTrue(result >= 1L) + } +} diff --git a/capy/src/test/java/com/jocmp/capy/fixtures/ArticleFixture.kt b/capy/src/test/java/com/jocmp/capy/fixtures/ArticleFixture.kt index 54ee4778d..b259dfe55 100644 --- a/capy/src/test/java/com/jocmp/capy/fixtures/ArticleFixture.kt +++ b/capy/src/test/java/com/jocmp/capy/fixtures/ArticleFixture.kt @@ -30,7 +30,8 @@ class ArticleFixture(private val database: Database) { published_at = publishedAt, summary = summary, url = "https://example.com/test-article", - enclosure_type = null + enclosure_type = null, + reading_time_minutes = null ) database.articlesQueries.createStatus( article_id = id, diff --git a/capy/src/test/java/com/jocmp/capy/persistence/ArticleMapperTest.kt b/capy/src/test/java/com/jocmp/capy/persistence/ArticleMapperTest.kt index 2110fc4a2..e3bd842e1 100644 --- a/capy/src/test/java/com/jocmp/capy/persistence/ArticleMapperTest.kt +++ b/capy/src/test/java/com/jocmp/capy/persistence/ArticleMapperTest.kt @@ -20,6 +20,7 @@ class ArticleMapperTest { 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, enclosureType = null, + readingTimeMinutes = null, feedTitle = "", faviconURL = null, enableStickyContent = false,