Improve global search

master
Koitharu 1 year ago
parent 6f67bd7542
commit 604efef832
Signed by: Koitharu
GPG Key ID: 676DEE768C17A9D7

@ -35,7 +35,15 @@ abstract class FavouritesDao : MangaQueryBuilder.ConditionCallback {
@Transaction @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") @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( fun observeAll(
order: ListSortOrder, order: ListSortOrder,

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

@ -26,7 +26,15 @@ abstract class HistoryDao : MangaQueryBuilder.ConditionCallback {
@Transaction @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") @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 @Transaction
@Query("SELECT * FROM history WHERE deleted_at = 0 ORDER BY updated_at DESC") @Query("SELECT * FROM history WHERE deleted_at = 0 ORDER BY updated_at DESC")

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

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

@ -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
}
}

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

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

@ -36,7 +36,7 @@
android:id="@+id/iconsView" android:id="@+id/iconsView"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="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:background="@drawable/bg_list_icons"
android:orientation="horizontal" android:orientation="horizontal"
android:padding="4dp" android:padding="4dp"

@ -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>

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

Loading…
Cancel
Save