Improve global search

This commit is contained in:
Koitharu
2025-02-18 15:20:25 +02:00
parent 6f67bd7542
commit 604efef832
11 changed files with 168 additions and 31 deletions

View File

@@ -35,7 +35,15 @@ abstract class FavouritesDao : MangaQueryBuilder.ConditionCallback {
@Transaction
@Query("SELECT manga.* FROM favourites LEFT JOIN manga ON manga.manga_id = favourites.manga_id WHERE favourites.deleted_at = 0 AND (manga.title LIKE :query OR manga.alt_title LIKE :query) LIMIT :limit")
abstract suspend fun search(query: String, limit: Int): List<MangaWithTags>
abstract suspend fun searchByTitle(query: String, limit: Int): List<MangaWithTags>
@Transaction
@Query("SELECT manga.* FROM favourites LEFT JOIN manga ON manga.manga_id = favourites.manga_id WHERE favourites.deleted_at = 0 AND (manga.author LIKE :query) LIMIT :limit")
abstract suspend fun searchByAuthor(query: String, limit: Int): List<MangaWithTags>
@Transaction
@Query("SELECT manga.* FROM favourites LEFT JOIN manga ON manga.manga_id = favourites.manga_id WHERE favourites.deleted_at = 0 AND EXISTS(SELECT 1 FROM tags LEFT JOIN manga_tags ON manga_tags.tag_id = tags.tag_id WHERE manga_tags.manga_id = manga.manga_id AND tags.title LIKE :query) LIMIT :limit")
abstract suspend fun searchByTag(query: String, limit: Int): List<MangaWithTags>
fun observeAll(
order: ListSortOrder,

View File

@@ -25,6 +25,7 @@ import org.koitharu.kotatsu.list.domain.ListSortOrder
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.levenshteinDistance
import org.koitharu.kotatsu.search.domain.SearchKind
import javax.inject.Inject
@Reusable
@@ -43,9 +44,17 @@ class FavouritesRepository @Inject constructor(
return entities.toMangaList()
}
suspend fun search(query: String, limit: Int): List<Manga> {
val entities = db.getFavouritesDao().search("%$query%", limit)
return entities.toMangaList().sortedBy { it.title.levenshteinDistance(query) }
suspend fun search(query: String, kind: SearchKind, limit: Int): List<Manga> {
val dao = db.getFavouritesDao()
val q = "%$query%"
val entities = when (kind) {
SearchKind.SIMPLE,
SearchKind.TITLE -> dao.searchByTitle(q, limit).sortedBy { it.manga.title.levenshteinDistance(query) }
SearchKind.AUTHOR -> dao.searchByAuthor(q, limit)
SearchKind.TAG -> dao.searchByTag(q, limit)
}
return entities.toMangaList()
}
fun observeAll(order: ListSortOrder, filterOptions: Set<ListFilterOption>, limit: Int): Flow<List<Manga>> {

View File

@@ -26,7 +26,15 @@ abstract class HistoryDao : MangaQueryBuilder.ConditionCallback {
@Transaction
@Query("SELECT manga.* FROM history LEFT JOIN manga ON manga.manga_id = history.manga_id WHERE history.deleted_at = 0 AND (manga.title LIKE :query OR manga.alt_title LIKE :query) LIMIT :limit")
abstract suspend fun search(query: String, limit: Int): List<MangaWithTags>
abstract suspend fun searchByTitle(query: String, limit: Int): List<MangaWithTags>
@Transaction
@Query("SELECT manga.* FROM history LEFT JOIN manga ON manga.manga_id = history.manga_id WHERE history.deleted_at = 0 AND (manga.author LIKE :query) LIMIT :limit")
abstract suspend fun searchByAuthor(query: String, limit: Int): List<MangaWithTags>
@Transaction
@Query("SELECT manga.* FROM history LEFT JOIN manga ON manga.manga_id = history.manga_id WHERE history.deleted_at = 0 AND EXISTS(SELECT 1 FROM tags LEFT JOIN manga_tags ON manga_tags.tag_id = tags.tag_id WHERE manga_tags.manga_id = manga.manga_id AND tags.title LIKE :query) LIMIT :limit")
abstract suspend fun searchByTag(query: String, limit: Int): List<MangaWithTags>
@Transaction
@Query("SELECT * FROM history WHERE deleted_at = 0 ORDER BY updated_at DESC")

View File

@@ -33,6 +33,7 @@ import org.koitharu.kotatsu.parsers.util.findById
import org.koitharu.kotatsu.parsers.util.levenshteinDistance
import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler
import org.koitharu.kotatsu.scrobbling.common.domain.tryScrobble
import org.koitharu.kotatsu.search.domain.SearchKind
import org.koitharu.kotatsu.tracker.domain.CheckNewChaptersUseCase
import javax.inject.Inject
import javax.inject.Provider
@@ -52,9 +53,17 @@ class HistoryRepository @Inject constructor(
return entities.map { it.toManga() }
}
suspend fun search(query: String, limit: Int): List<Manga> {
val entities = db.getHistoryDao().search("%$query%", limit)
return entities.toMangaList().sortedBy { it.title.levenshteinDistance(query) }
suspend fun search(query: String, kind: SearchKind, limit: Int): List<Manga> {
val dao = db.getHistoryDao()
val q = "%$query%"
val entities = when (kind) {
SearchKind.SIMPLE,
SearchKind.TITLE -> dao.searchByTitle(q, limit).sortedBy { it.manga.title.levenshteinDistance(query) }
SearchKind.AUTHOR -> dao.searchByAuthor(q, limit)
SearchKind.TAG -> dao.searchByTag(q, limit)
}
return entities.toMangaList()
}
suspend fun getCount(): Int {

View File

@@ -99,6 +99,8 @@ class SearchActivity :
setSubtitle(R.string.search_results)
}
addMenuProvider(SearchKindMenuProvider(this, viewModel.query, viewModel.kind))
viewModel.list.observe(this, adapter)
viewModel.onError.observeEvent(this, SnackbarErrorObserver(viewBinding.recyclerView, null))
}

View File

@@ -0,0 +1,56 @@
package org.koitharu.kotatsu.search.ui.multi
import android.os.Build
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import androidx.core.view.MenuProvider
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.nav.router
import org.koitharu.kotatsu.search.domain.SearchKind
class SearchKindMenuProvider(
private val activity: SearchActivity,
private val query: String,
private val kind: SearchKind
) : MenuProvider {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.opt_search_kind, menu)
}
override fun onPrepareMenu(menu: Menu) {
super.onPrepareMenu(menu)
menu.findItem(
when (kind) {
SearchKind.SIMPLE -> R.id.action_kind_simple
SearchKind.TITLE -> R.id.action_kind_title
SearchKind.AUTHOR -> R.id.action_kind_author
SearchKind.TAG -> R.id.action_kind_tag
},
)?.isChecked = true
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
val newKind = when (menuItem.itemId) {
R.id.action_kind_simple -> SearchKind.SIMPLE
R.id.action_kind_title -> SearchKind.TITLE
R.id.action_kind_author -> SearchKind.AUTHOR
R.id.action_kind_tag -> SearchKind.TAG
else -> return false
}
if (newKind != kind) {
activity.router.openSearch(
query = query,
kind = newKind,
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
activity.overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out, 0)
} else {
activity.overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out)
}
activity.finishAfterTransition()
}
return true
}
}

View File

@@ -37,10 +37,7 @@ import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingFooter
import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.search.domain.SearchKind
import org.koitharu.kotatsu.search.domain.SearchV2Helper
@@ -56,7 +53,6 @@ class SearchViewModel @Inject constructor(
private val searchHelperFactory: SearchV2Helper.Factory,
private val sourcesRepository: MangaSourcesRepository,
private val historyRepository: HistoryRepository,
private val localMangaRepository: LocalMangaRepository,
private val favouritesRepository: FavouritesRepository,
) : BaseViewModel() {
@@ -65,7 +61,7 @@ class SearchViewModel @Inject constructor(
private val retryCounter = MutableStateFlow(0)
private val listData = retryCounter.flatMapLatest {
searchImpl(query).withLoading().withErrorHandling()
searchImpl().withLoading().withErrorHandling()
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
val list: StateFlow<List<ListModel>> = combine(
@@ -108,10 +104,10 @@ class SearchViewModel @Inject constructor(
}
@CheckResult
private fun searchImpl(q: String): Flow<List<SearchResultsListModel>> = channelFlow {
searchHistory(q)?.let { send(it) }
searchFavorites(q)?.let { send(it) }
searchLocal(q)?.let { send(it) }
private fun searchImpl(): Flow<List<SearchResultsListModel>> = channelFlow {
searchHistory()?.let { send(it) }
searchFavorites()?.let { send(it) }
searchLocal()?.let { send(it) }
val sources = sourcesRepository.getEnabledSources()
if (sources.isEmpty()) {
return@channelFlow
@@ -158,9 +154,9 @@ class SearchViewModel @Inject constructor(
.filterNotNull()
.onEmpty { emit(emptyList()) }
private suspend fun searchHistory(q: String): SearchResultsListModel? {
private suspend fun searchHistory(): SearchResultsListModel? {
return runCatchingCancellable {
historyRepository.search(q, Int.MAX_VALUE)
historyRepository.search(query, kind, Int.MAX_VALUE)
}.fold(
onSuccess = { result ->
if (result.isNotEmpty()) {
@@ -191,9 +187,9 @@ class SearchViewModel @Inject constructor(
)
}
private suspend fun searchFavorites(q: String): SearchResultsListModel? {
private suspend fun searchFavorites(): SearchResultsListModel? {
return runCatchingCancellable {
favouritesRepository.search(q, Int.MAX_VALUE)
favouritesRepository.search(query, kind, Int.MAX_VALUE)
}.fold(
onSuccess = { result ->
if (result.isNotEmpty()) {
@@ -201,7 +197,11 @@ class SearchViewModel @Inject constructor(
titleResId = R.string.favourites,
source = UnknownMangaSource,
hasMore = false,
list = mangaListMapper.toListModelList(manga = result, mode = ListMode.GRID),
list = mangaListMapper.toListModelList(
manga = result,
mode = ListMode.GRID,
flags = MangaListMapper.NO_FAVORITE,
),
error = null,
listFilter = null,
sortOrder = null,
@@ -224,20 +224,24 @@ class SearchViewModel @Inject constructor(
)
}
private suspend fun searchLocal(q: String): SearchResultsListModel? {
private suspend fun searchLocal(): SearchResultsListModel? {
return runCatchingCancellable {
localMangaRepository.getList(0, SortOrder.RELEVANCE, MangaListFilter(query = q))
searchHelperFactory.create(LocalMangaSource).invoke(query, kind)
}.fold(
onSuccess = { result ->
if (result.isNotEmpty()) {
if (!result?.manga.isNullOrEmpty()) {
SearchResultsListModel(
titleResId = 0,
source = LocalMangaSource,
hasMore = result.size > MIN_HAS_MORE_ITEMS,
list = mangaListMapper.toListModelList(manga = result, mode = ListMode.GRID),
hasMore = result.manga.size > MIN_HAS_MORE_ITEMS,
list = mangaListMapper.toListModelList(
manga = result.manga,
mode = ListMode.GRID,
flags = MangaListMapper.NO_SAVED,
),
error = null,
listFilter = null,
sortOrder = null,
listFilter = result.listFilter,
sortOrder = result.sortOrder,
)
} else {
null

View File

@@ -39,7 +39,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="top|start"
android:layout_marginTop="@dimen/card_indicator_offset"
android:layout_marginTop="10dp"
android:background="@drawable/bg_list_icons"
android:orientation="horizontal"
android:padding="4dp"

View File

@@ -36,7 +36,7 @@
android:id="@+id/iconsView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/card_indicator_offset"
android:layout_marginTop="10dp"
android:background="@drawable/bg_list_icons"
android:orientation="horizontal"
android:padding="4dp"

View File

@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<menu
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_search_kind"
android:icon="@drawable/ic_filter_menu"
android:orderInCategory="0"
android:title="@string/type"
app:showAsAction="ifRoom">
<menu>
<group
android:id="@+id/group_search_kind"
android:checkableBehavior="single">
<item
android:id="@+id/action_kind_simple"
android:title="@string/simple" />
<item
android:id="@+id/action_kind_title"
android:title="@string/name" />
<item
android:id="@+id/action_kind_author"
android:title="@string/author" />
<item
android:id="@+id/action_kind_tag"
android:title="@string/genre" />
</group>
</menu>
</item>
</menu>

View File

@@ -802,4 +802,5 @@
<string name="screen_rotation_unlocked">Screen rotation has been unlocked</string>
<string name="badges_in_lists">Badges in lists</string>
<string name="search_everywhere">Search everywhere</string>
<string name="simple">Simple</string>
</resources>