Skip to content

Commit

Permalink
Support Full-text Search for articles (#61)
Browse files Browse the repository at this point in the history
* Add Required dependencies to support fts5

* Write FTS5 SQL queries

* Generate Database files

* Update Dao

* Update tokenize algorithm & rank results using bm25

* Update UI & presenter to allow user typing into search bar

* Apply style formatting
  • Loading branch information
mr3y-the-programmer authored Nov 10, 2023
1 parent d31a91d commit da0e458
Show file tree
Hide file tree
Showing 18 changed files with 236 additions and 24 deletions.
1 change: 1 addition & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ okio = "com.squareup.okio:okio:3.6.0"
sqldelight-android = "app.cash.sqldelight:android-driver:2.0.0"
sqldelight-jvm = "app.cash.sqldelight:sqlite-driver:2.0.0"
sqldelight-paging = "app.cash.sqldelight:androidx-paging3-extensions:2.0.0"
sqlite-android = "com.github.requery:sqlite-android:3.43.0"
kotlin-inject-ksp = "me.tatarka.inject:kotlin-inject-compiler-ksp:0.6.3"
kotlin-inject-runtime = "me.tatarka.inject:kotlin-inject-runtime:0.6.3"
lyricist-core = "cafe.adriel.lyricist:lyricist:1.4.2"
Expand Down
4 changes: 4 additions & 0 deletions shared/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,8 @@ kotlin {
dependencies {
// Database driver
implementation(libs.sqldelight.android)
// Unbundled sqlite
implementation(libs.sqlite.android)
// lifecycle
implementation(libs.androidx.lifecycle.runtime.compose)
// Coil
Expand Down Expand Up @@ -177,6 +179,8 @@ sqldelight {
create("LudiDatabase") {
packageName.set("com.mr3y.ludi.shared.core.database")
generateAsync.set(true)
schemaOutputDirectory.set(file("src/commonMain/sqldelight/databases"))
verifyMigrations.set(true)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,17 @@ import android.content.Context
import app.cash.sqldelight.async.coroutines.synchronous
import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.driver.android.AndroidSqliteDriver
import io.requery.android.database.sqlite.RequerySQLiteOpenHelperFactory
import me.tatarka.inject.annotations.Inject

@Inject
actual class DriverFactory(private val applicationContext: Context) {
actual fun createDriver(): SqlDriver {
return AndroidSqliteDriver(schema = LudiDatabase.Schema.synchronous(), context = applicationContext, name = "ludi_database.db")
return AndroidSqliteDriver(
schema = LudiDatabase.Schema.synchronous(),
context = applicationContext,
name = "ludi_database.db",
factory = RequerySQLiteOpenHelperFactory()
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ actual fun NewsScreen(
val tabToolbarColor = MaterialTheme.colorScheme.chromeCustomTabToolbarColor
NewsScreen(
newsState = newsState,
searchQuery = viewModel.searchQuery.value,
onSearchQueryValueChanged = viewModel::updateSearchQuery,
onTuneClick = onTuneClick,
onRefresh = viewModel::refresh,
onOpenUrl = { url ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import com.mr3y.ludi.shared.ArticleEntity
import com.mr3y.ludi.shared.core.model.NewReleaseArticle
import com.mr3y.ludi.shared.core.model.NewsArticle
import com.mr3y.ludi.shared.core.model.ReviewArticle
import com.mr3y.ludi.shared.core.model.Source
import java.time.ZonedDateTime

fun NewsArticle.toArticleEntity(): ArticleEntity {
return ArticleEntity(
Expand Down Expand Up @@ -82,3 +84,27 @@ fun ArticleEntity.toNewReleaseArticle(): NewReleaseArticle {
releaseDate = publicationDate!!
)
}

fun mapToArticleEntity(
title: String,
description: String?,
source: String,
content: String?,
author: String?,
sourceLinkUrl: String,
imageUrl: String?,
publicationDate: ZonedDateTime?,
type: String
): ArticleEntity {
return ArticleEntity(
title = TitleColumnAdapter.decode(title),
description = description?.let { MarkupTextColumnAdapter.decode(it) },
source = Source.valueOf(source),
content = content?.let { MarkupTextColumnAdapter.decode(it) },
author = author,
sourceLinkUrl = sourceLinkUrl,
imageUrl = imageUrl,
publicationDate = publicationDate,
type = type
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import app.cash.paging.PagingSource
import app.cash.sqldelight.paging3.QueryPagingSource
import com.mr3y.ludi.shared.ArticleEntity
import com.mr3y.ludi.shared.core.database.LudiDatabase
import com.mr3y.ludi.shared.core.database.mapToArticleEntity
import com.mr3y.ludi.shared.core.database.toArticleEntity
import com.mr3y.ludi.shared.core.model.Article
import com.mr3y.ludi.shared.core.model.NewReleaseArticle
Expand All @@ -14,9 +15,9 @@ import kotlinx.coroutines.withContext
import me.tatarka.inject.annotations.Inject

interface ArticleEntitiesDao {
fun queryNewsArticles(): PagingSource<Int, ArticleEntity>
fun queryNewsArticles(searchQuery: String? = null): PagingSource<Int, ArticleEntity>

fun queryReviewArticles(): PagingSource<Int, ArticleEntity>
fun queryReviewArticles(searchQuery: String? = null): PagingSource<Int, ArticleEntity>

fun queryNewReleaseArticles(): PagingSource<Int, ArticleEntity>

Expand All @@ -32,24 +33,40 @@ class DefaultArticleEntitiesDao(
private val database: LudiDatabase,
private val dispatcherWrapper: DatabaseDispatcher
) : ArticleEntitiesDao {
override fun queryNewsArticles(): PagingSource<Int, ArticleEntity> {
override fun queryNewsArticles(searchQuery: String?): PagingSource<Int, ArticleEntity> {
return QueryPagingSource(
countQuery = database.articleQueries.countArticles("news"),
transacter = database.articleQueries,
countQuery = if (!searchQuery.isNullOrBlank()) {
database.articleSearchFTSQueries.countSearchResults(searchQuery.wildcardMatch().sanitize(), "news")
} else {
database.articleQueries.countArticles("news")
},
transacter = if (!searchQuery.isNullOrBlank()) database.articleSearchFTSQueries else database.articleQueries,
context = dispatcherWrapper.dispatcher,
queryProvider = { limit, offset ->
database.articleQueries.queryArticles("news", limit, offset)
if (!searchQuery.isNullOrBlank()) {
database.articleSearchFTSQueries.search(searchQuery.wildcardMatch().sanitize(), "news", limit, offset, ::mapToArticleEntity)
} else {
database.articleQueries.queryArticles("news", limit, offset)
}
}
)
}

override fun queryReviewArticles(): PagingSource<Int, ArticleEntity> {
override fun queryReviewArticles(searchQuery: String?): PagingSource<Int, ArticleEntity> {
return QueryPagingSource(
countQuery = database.articleQueries.countArticles("reviews"),
transacter = database.articleQueries,
countQuery = if (!searchQuery.isNullOrBlank()) {
database.articleSearchFTSQueries.countSearchResults(searchQuery.wildcardMatch().sanitize(), "reviews")
} else {
database.articleQueries.countArticles("reviews")
},
transacter = if (!searchQuery.isNullOrBlank()) database.articleSearchFTSQueries else database.articleQueries,
context = dispatcherWrapper.dispatcher,
queryProvider = { limit, offset ->
database.articleQueries.queryArticles("reviews", limit, offset)
if (!searchQuery.isNullOrBlank()) {
database.articleSearchFTSQueries.search(searchQuery.wildcardMatch().sanitize(), "reviews", limit, offset, ::mapToArticleEntity)
} else {
database.articleQueries.queryArticles("reviews", limit, offset)
}
}
)
}
Expand Down Expand Up @@ -89,4 +106,8 @@ class DefaultArticleEntitiesDao(
}
}
}

private fun String.wildcardMatch() = "*$this*"

private fun String.sanitize() = "\"$this\""
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,11 @@ import kotlinx.coroutines.flow.Flow

interface NewsRepository {

fun queryLatestGamingNews(): Flow<PagingData<NewsArticle>>
fun queryLatestGamingNews(searchQuery: String?): Flow<PagingData<NewsArticle>>

fun queryGamesNewReleases(): Flow<PagingData<NewReleaseArticle>>

fun queryGamesReviews(): Flow<PagingData<ReviewArticle>>
fun queryGamesReviews(searchQuery: String?): Flow<PagingData<ReviewArticle>>

suspend fun updateGamingNews(sources: Set<Source>, forceRefresh: Boolean): Boolean

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,9 @@ class DefaultNewsRepository(
private val logger: Logger
) : NewsRepository {

override fun queryLatestGamingNews(): Flow<PagingData<NewsArticle>> {
override fun queryLatestGamingNews(searchQuery: String?): Flow<PagingData<NewsArticle>> {
return Pager(DefaultPagingConfig) {
articleEntitiesDao.queryNewsArticles()
articleEntitiesDao.queryNewsArticles(searchQuery)
}
.flow
.map { pagingData ->
Expand All @@ -58,9 +58,9 @@ class DefaultNewsRepository(
}
}

override fun queryGamesReviews(): Flow<PagingData<ReviewArticle>> {
override fun queryGamesReviews(searchQuery: String?): Flow<PagingData<ReviewArticle>> {
return Pager(DefaultPagingConfig) {
articleEntitiesDao.queryReviewArticles()
articleEntitiesDao.queryReviewArticles(searchQuery)
}
.flow
.map { pagingData ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ fun rememberParallaxAlignment(
// Read the LazyListState layout info
val layoutInfo = lazyListState.layoutInfo
// Find the layout info of this item
val itemInfo = layoutInfo.visibleItemsInfo.first { it.key == key }
val itemInfo = layoutInfo.visibleItemsInfo.firstOrNull { it.key == key } ?: return@ParallaxAlignment 0f

val adjustedOffset = itemInfo.offset - layoutInfo.viewportStartOffset
(adjustedOffset / itemInfo.size.toFloat()).coerceIn(-1f, 1f)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.mr3y.ludi.shared.ui.presenter

import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.snapshotFlow
import androidx.datastore.core.DataStore
import androidx.paging.cachedIn
import cafe.adriel.voyager.core.model.ScreenModel
Expand All @@ -10,19 +12,22 @@ import com.mr3y.ludi.shared.core.repository.NewsRepository
import com.mr3y.ludi.shared.ui.presenter.model.NewsState
import com.mr3y.ludi.shared.ui.presenter.model.NewsStateEvent
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.update
import me.tatarka.inject.annotations.Inject

@OptIn(ExperimentalCoroutinesApi::class)
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
@Inject
class NewsViewModel(
private val newsRepository: NewsRepository,
Expand All @@ -40,11 +45,21 @@ class NewsViewModel(
.ifEmpty { Source.values().toSet() }
}

val searchQuery = mutableStateOf("")

private val refreshing = MutableStateFlow(0)

private val newsArticlesFeed = newsRepository.queryLatestGamingNews().cachedIn(screenModelScope)
private val newsArticlesFeed = snapshotFlow { searchQuery.value }
.debounce(275)
.flatMapLatest { text ->
newsRepository.queryLatestGamingNews(text.takeIf { it.length > 3 })
}.cachedIn(screenModelScope)

private val reviewArticlesFeed = newsRepository.queryGamesReviews().cachedIn(screenModelScope)
private val reviewArticlesFeed = snapshotFlow { searchQuery.value }
.debounce(275)
.flatMapLatest { text ->
newsRepository.queryGamesReviews(text.takeIf { it.length > 3 })
}.cachedIn(screenModelScope)

private val newReleaseArticlesFeed = newsRepository.queryGamesNewReleases().cachedIn(screenModelScope)

Expand Down Expand Up @@ -92,6 +107,10 @@ class NewsViewModel(
refreshing.update { it + 1 }
}

fun updateSearchQuery(text: String) {
searchQuery.value = text
}

fun consumeCurrentEvent() {
_internalState.update { it.copy(currentEvent = null) }
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ val EnLudiStrings = LudiStrings(
discover_page_filters_sheet_close_content_description = "Close filters sheet",
filter_chip_on_state_desc = { "Unselect $it" },
filter_chip_off_state_desc = { "Select $it" },
news_page_search_bar_placeholder = "looking for a specific article?",
news_page_search_bar_content_description = "Search for a specific article",
news_page_filter_icon_content_description = "Customize news feed",
news_page_article_content_description = { title, author, source -> "$title\nPublished by $author on $source" },
news_page_new_release_content_description = { name, date -> "$name is expected to have a new release on $date" },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ data class LudiStrings(
val discover_page_filters_sheet_close_content_description: String,
val filter_chip_on_state_desc: (String) -> String,
val filter_chip_off_state_desc: (String) -> String,
val news_page_search_bar_placeholder: String,
val news_page_search_bar_content_description: String,
val news_page_filter_icon_content_description: String,
val news_page_article_content_description: (title: String, author: String, source: String) -> String,
val news_page_new_release_content_description: (name: String, date: String) -> String,
Expand Down
Loading

0 comments on commit da0e458

Please sign in to comment.