Skip to content

Commit

Permalink
Added reading time/word count for languages which use spaces
Browse files Browse the repository at this point in the history
  • Loading branch information
spacecowboy committed Nov 24, 2023
1 parent e89df3b commit 510a38a
Show file tree
Hide file tree
Showing 26 changed files with 1,989 additions and 29 deletions.
743 changes: 743 additions & 0 deletions app/schemas/com.nononsenseapps.feeder.db.room.AppDatabase/30.json

Large diffs are not rendered by default.

749 changes: 749 additions & 0 deletions app/schemas/com.nononsenseapps.feeder.db.room.AppDatabase/31.json

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package com.nononsenseapps.feeder.db.room

import androidx.room.testing.MigrationTestHelper
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import androidx.test.platform.app.InstrumentationRegistry
import com.nononsenseapps.feeder.FeederApplication
import kotlin.test.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.kodein.di.DI
import org.kodein.di.DIAware
import org.kodein.di.android.closestDI

@RunWith(AndroidJUnit4::class)
@LargeTest
class TestMigrationFrom29To30 : DIAware {
private val dbName = "testDb"
private val feederApplication: FeederApplication = ApplicationProvider.getApplicationContext()
override val di: DI by closestDI(feederApplication)

@Rule
@JvmField
val testHelper: MigrationTestHelper = MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(),
AppDatabase::class.java,
emptyList(),
FrameworkSQLiteOpenHelperFactory(),
)

@Test
fun migrate() {
@Suppress("SimpleRedundantLet")
testHelper.createDatabase(dbName, FROM_VERSION).let { oldDB ->
oldDB.execSQL(
"""
INSERT INTO feeds(id, title, url, custom_title, tag, notify, last_sync, response_hash, fulltext_by_default, open_articles_with, alternate_id, currently_syncing, when_modified, site_fetched)
VALUES(1, 'feed', 'http://url', '', '', 0, 0, 666, 0, '', 0, 0, 0, 0)
""".trimIndent(),
)
oldDB.execSQL(
"""
INSERT INTO feed_items(id, guid, title, plain_title, plain_snippet, notified, feed_id, first_synced_time, primary_sort_time, pinned, bookmarked, fulltext_downloaded, read_time, unread)
VALUES(8, 'http://item1', 'title', 'ptitle', 'psnippet', 0, 1, 0, 0, 1, 0, 0, 0, 1)
""".trimIndent(),
)
}
val db = testHelper.runMigrationsAndValidate(
dbName,
TO_VERSION,
true,
MigrationFrom29To30(di),
)

db.query(
"""
SELECT word_count FROM feed_items
""".trimIndent(),
).use {
assert(it.count == 1)
assert(it.moveToFirst())
assertEquals(0, it.getInt(0))
}
}

companion object {
private const val FROM_VERSION = 29
private const val TO_VERSION = 30
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package com.nononsenseapps.feeder.db.room

import androidx.room.testing.MigrationTestHelper
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import androidx.test.platform.app.InstrumentationRegistry
import com.nononsenseapps.feeder.FeederApplication
import kotlin.test.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.kodein.di.DI
import org.kodein.di.DIAware
import org.kodein.di.android.closestDI

@RunWith(AndroidJUnit4::class)
@LargeTest
class TestMigrationFrom30To31 : DIAware {
private val dbName = "testDb"
private val feederApplication: FeederApplication = ApplicationProvider.getApplicationContext()
override val di: DI by closestDI(feederApplication)

@Rule
@JvmField
val testHelper: MigrationTestHelper = MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(),
AppDatabase::class.java,
emptyList(),
FrameworkSQLiteOpenHelperFactory(),
)

@Test
fun migrate() {
@Suppress("SimpleRedundantLet")
testHelper.createDatabase(dbName, FROM_VERSION).let { oldDB ->
oldDB.execSQL(
"""
INSERT INTO feeds(id, title, url, custom_title, tag, notify, last_sync, response_hash, fulltext_by_default, open_articles_with, alternate_id, currently_syncing, when_modified, site_fetched)
VALUES(1, 'feed', 'http://url', '', '', 0, 0, 666, 0, '', 0, 0, 0, 0)
""".trimIndent(),
)
oldDB.execSQL(
"""
INSERT INTO feed_items(id, guid, title, plain_title, plain_snippet, notified, feed_id, first_synced_time, primary_sort_time, pinned, bookmarked, fulltext_downloaded, read_time, unread, word_count)
VALUES(8, 'http://item1', 'title', 'ptitle', 'psnippet', 0, 1, 0, 0, 1, 0, 0, 0, 1, 5)
""".trimIndent(),
)
}
val db = testHelper.runMigrationsAndValidate(
dbName,
TO_VERSION,
true,
MigrationFrom30To31(di),
)

db.query(
"""
SELECT word_count_full FROM feed_items
""".trimIndent(),
).use {
assert(it.count == 1)
assert(it.moveToFirst())
assertEquals(0, it.getInt(0))
}
}

companion object {
private const val FROM_VERSION = 30
private const val TO_VERSION = 31
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,10 @@ class FeedItemStore(override val di: DI) : DIAware {
dao.deleteFeedItems(ids)
}

suspend fun updateWordCountFull(id: Long, wordCount: Int) {
dao.updateWordCountFull(id, wordCount)
}

companion object {
private const val PAGE_SIZE = 100
}
Expand All @@ -315,6 +319,7 @@ private fun PreviewItem.toFeedListItem() =
feedImageUrl = feedImageUrl,
rawPubDate = pubDate,
primarySortTime = primarySortTime,
wordCount = bestWordCount,
)

private fun LocalDateTime.formatDynamically(): String {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -652,6 +652,15 @@ class Repository(override val di: DI) : DIAware {
settingsStore.setOpenAdjacent(value)
}

val showReadingTime: StateFlow<Boolean> = settingsStore.showReadingTime
fun setShowReadingTime(value: Boolean) {
settingsStore.setShowReadingTime(value)
}

suspend fun updateWordCountFull(id: Long, wordCount: Int) {
feedItemStore.updateWordCountFull(id, wordCount)
}

companion object {
private const val LOG_TAG = "FEEDER_REPO"
}
Expand Down Expand Up @@ -713,6 +722,8 @@ data class Article(
val feedId: Long = item?.feedId ?: ID_UNSET
val feedUrl: String? = item?.feedUrl?.toString()
val bookmarked: Boolean = item?.bookmarked ?: false
val wordCount: Int = item?.wordCount ?: 0
val wordCountFull: Int = item?.wordCountFull ?: 0
}

enum class TextToDisplay {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,13 @@ class SettingsStore(override val di: DI) : DIAware {
sp.edit().putBoolean(PREF_OPEN_ADJACENT, value).apply()
}

private val _showReadingTime = MutableStateFlow(sp.getBoolean(PREF_LIST_SHOW_READING_TIME, false))
val showReadingTime = _showReadingTime.asStateFlow()
fun setShowReadingTime(value: Boolean) {
_showReadingTime.value = value
sp.edit().putBoolean(PREF_LIST_SHOW_READING_TIME, value).apply()
}

private val _feedItemStyle = MutableStateFlow(
feedItemStyleFromString(sp.getStringNonNull(PREF_FEED_ITEM_STYLE, FeedItemStyle.CARD.name)),
)
Expand Down Expand Up @@ -510,6 +517,8 @@ const val PREFS_FILTER_READ = "prefs_filter_read"

const val PREF_LIST_SHOW_ONLY_TITLES = "prefs_list_show_only_titles"

const val PREF_LIST_SHOW_READING_TIME = "pref_show_reading_time"

/**
* Read Aloud Settings
*/
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/java/com/nononsenseapps/feeder/db/Constants.kt
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ const val COL_GLOB_PATTERN = "glob_pattern"
const val COL_FULLTEXT_DOWNLOADED = "fulltext_downloaded"
const val COL_READ_TIME = "read_time"
const val COL_SITE_FETCHED = "site_fetched"
const val COL_WORD_COUNT = "word_count"
const val COL_WORD_COUNT_FULL = "word_count_full"

// year 5000
val FAR_FUTURE = Instant.ofEpochSecond(95635369646)
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ private const val LOG_TAG = "FEEDER_APPDB"
RemoteFeed::class,
SyncDevice::class,
],
version = 29,
version = 31,
)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() {
Expand Down Expand Up @@ -116,12 +116,34 @@ fun getAllMigrations(di: DI) = arrayOf(
MigrationFrom26To27(di),
MigrationFrom27To28(di),
MigrationFrom28To29(di),
MigrationFrom29To30(di),
MigrationFrom30To31(di),
)

/*
* 6 represents legacy database
* 7 represents new Room database
*/
class MigrationFrom30To31(override val di: DI) : Migration(30, 31), DIAware {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL(
"""
alter table feed_items add column word_count_full integer not null default 0
""".trimIndent(),
)
}
}

class MigrationFrom29To30(override val di: DI) : Migration(29, 30), DIAware {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL(
"""
alter table feed_items add column word_count integer not null default 0
""".trimIndent(),
)
}
}

class MigrationFrom28To29(override val di: DI) : Migration(28, 29), DIAware {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL(
Expand Down
62 changes: 54 additions & 8 deletions app/src/main/java/com/nononsenseapps/feeder/db/room/FeedItem.kt
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import com.nononsenseapps.feeder.db.COL_PRIMARYSORTTIME
import com.nononsenseapps.feeder.db.COL_PUBDATE
import com.nononsenseapps.feeder.db.COL_READ_TIME
import com.nononsenseapps.feeder.db.COL_TITLE
import com.nononsenseapps.feeder.db.COL_WORD_COUNT
import com.nononsenseapps.feeder.db.COL_WORD_COUNT_FULL
import com.nononsenseapps.feeder.db.FEED_ITEMS_TABLE_NAME
import com.nononsenseapps.feeder.model.host
import com.nononsenseapps.feeder.ui.text.HtmlToPlainTextConverter
Expand All @@ -38,6 +40,8 @@ import java.time.ZonedDateTime
const val MAX_TITLE_LENGTH = 200
const val MAX_SNIPPET_LENGTH = 200

private val patternWhitespace = "\\s+".toRegex()

@Entity(
tableName = FEED_ITEMS_TABLE_NAME,
indices = [
Expand Down Expand Up @@ -72,21 +76,38 @@ data class FeedItem @Ignore constructor(
@ColumnInfo(name = COL_ENCLOSURELINK) var enclosureLink: String? = null,
@ColumnInfo(name = COL_ENCLOSURE_TYPE) var enclosureType: String? = null,
@ColumnInfo(name = COL_AUTHOR) var author: String? = null,
@ColumnInfo(name = COL_PUBDATE, typeAffinity = ColumnInfo.TEXT) override var pubDate: ZonedDateTime? = null,
@ColumnInfo(
name = COL_PUBDATE,
typeAffinity = ColumnInfo.TEXT,
) override var pubDate: ZonedDateTime? = null,
@ColumnInfo(name = COL_LINK) override var link: String? = null,
@Deprecated("This column has been 'removed' but sqlite doesn't support drop column.", replaceWith = ReplaceWith("readTime"))
@Deprecated(
"This column has been 'removed' but sqlite doesn't support drop column.",
replaceWith = ReplaceWith("readTime"),
)
@ColumnInfo(name = "unread")
var oldUnread: Boolean = true,
@ColumnInfo(name = COL_NOTIFIED) var notified: Boolean = false,
@ColumnInfo(name = COL_FEEDID) var feedId: Long? = null,
@ColumnInfo(name = COL_FIRSTSYNCEDTIME, typeAffinity = ColumnInfo.INTEGER) var firstSyncedTime: Instant = Instant.EPOCH,
@ColumnInfo(name = COL_PRIMARYSORTTIME, typeAffinity = ColumnInfo.INTEGER) override var primarySortTime: Instant = Instant.EPOCH,
@ColumnInfo(
name = COL_FIRSTSYNCEDTIME,
typeAffinity = ColumnInfo.INTEGER,
) var firstSyncedTime: Instant = Instant.EPOCH,
@ColumnInfo(
name = COL_PRIMARYSORTTIME,
typeAffinity = ColumnInfo.INTEGER,
) override var primarySortTime: Instant = Instant.EPOCH,
@Deprecated("This column has been 'removed' but sqlite doesn't support drop column.")
@ColumnInfo(name = "pinned")
var oldPinned: Boolean = false,
@ColumnInfo(name = COL_BOOKMARKED) var bookmarked: Boolean = false,
@ColumnInfo(name = COL_FULLTEXT_DOWNLOADED) var fullTextDownloaded: Boolean = false,
@ColumnInfo(name = COL_READ_TIME, typeAffinity = ColumnInfo.INTEGER) var readTime: Instant? = null,
@ColumnInfo(
name = COL_READ_TIME,
typeAffinity = ColumnInfo.INTEGER,
) var readTime: Instant? = null,
@ColumnInfo(name = COL_WORD_COUNT) var wordCount: Int = 0,
@ColumnInfo(name = COL_WORD_COUNT_FULL) var wordCountFull: Int = 0,
) : FeedItemForFetching, FeedItemCursor {

constructor() : this(id = ID_UNSET)
Expand All @@ -101,10 +122,17 @@ data class FeedItem @Ignore constructor(
) {
val converter = HtmlToPlainTextConverter()
// Be careful about nulls.
val text = entry.content_html ?: entry.content_text ?: ""
val plainText = converter.convert(
entry.content_html
?: entry.content_text
?: "",
)
this.wordCount = estimateWordCount(plainText)

val summary: String = (
entry.summary ?: entry.content_text
?: converter.convert(text)
entry.summary
?: entry.content_text
?: plainText
).take(MAX_SNIPPET_LENGTH)

// Make double sure no base64 images are used as thumbnails
Expand All @@ -117,6 +145,7 @@ data class FeedItem @Ignore constructor(
feed.feed_url != null && safeImage != null -> {
relativeLinkIntoAbsolute(sloppyLinkToStrictURL(feed.feed_url), safeImage)
}

else -> safeImage
}

Expand Down Expand Up @@ -178,3 +207,20 @@ interface FeedItemCursor {
val pubDate: ZonedDateTime?
val id: Long
}

/**
* If language doesn't use spaces, then this function will try to return 0
*/
fun estimateWordCount(plainText: String): Int {
val charCount = plainText.length.toFloat()
val wordCount = plainText.splitToSequence(patternWhitespace).count()

// Calculate average length of chars between spaces
// A typical value for english is 5-7
// A typical value for japanese is 50-80
return if (charCount / wordCount < 15.0) {
wordCount
} else {
0
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,15 @@ interface FeedItemDao {
)
suspend fun deleteFeedItems(ids: List<Long>): Int

@Query(
"""
update feed_items
set word_count_full = :wordCount
where id = :id
""",
)
suspend fun updateWordCountFull(id: Long, wordCount: Int)

@Query(
"""
SELECT id FROM feed_items
Expand Down
Loading

0 comments on commit 510a38a

Please sign in to comment.