fix: 搜索问题

This commit is contained in:
HapeLee
2026-06-14 00:21:15 +08:00
parent 83bc818544
commit 3fdea120da
6 changed files with 208 additions and 125 deletions
@@ -14,6 +14,7 @@ val Context.localDataStore: DataStore<Preferences> by preferencesDataStore(name
object LocalPreferencesKeys {
val SHOW_THEME_REFACTOR_TIP = booleanPreferencesKey("show_theme_refactor_tip")
val SEARCH_LAYOUT_MODE = intPreferencesKey("search_layout_mode")
val SEARCH_SCOPE = stringPreferencesKey("search_scope")
val MATCH_MODE = intPreferencesKey("match_mode")
val EXPLORE_LAYOUT_MODE = intPreferencesKey("explore_layout_mode")
val EXPLORE_LAYOUT_GRID_PORTRAIT = intPreferencesKey("explore_layout_grid_portrait")
@@ -1,40 +1,130 @@
package io.legado.app.domain.model
import com.google.gson.GsonBuilder
import io.legado.app.utils.fromJsonObject
import io.legado.app.utils.splitNotBlank
data class BookSearchScope(val raw: String) {
val items: List<String>
get() = raw.splitNotBlank(",").toList()
val isAll: Boolean
get() = raw.isEmpty()
val isSource: Boolean
get() = items.isNotEmpty() && parseSourceItems().size == items.size
val groupNames: List<String>
get() = if (isSource) emptyList() else items
val sourceUrls: List<String>
get() = parseSourceItems().map { it.url }
private fun parseSourceItems(): List<ScopeSourceItem> {
return items.mapNotNull { item ->
val splitIndex = item.indexOf("::")
if (splitIndex <= 0 || splitIndex >= item.lastIndex) {
null
} else {
ScopeSourceItem(
name = item.substring(0, splitIndex),
url = item.substring(splitIndex + 2)
)
}
}
private val parsed: ParsedSearchScope by lazy {
parse(raw)
}
private data class ScopeSourceItem(
val items: List<String>
get() = when {
isSource -> sourceUrls
else -> groupNames
}
val isAll: Boolean
get() = parsed.isAll
val isSource: Boolean
get() = parsed.sources.isNotEmpty()
val groupNames: List<String>
get() = parsed.groups
val sourceUrls: List<String>
get() = parsed.sources.map { it.url }
val sourceNames: List<String>
get() = parsed.sources.map { it.name }
private data class ParsedSearchScope(
val groups: List<String> = emptyList(),
val sources: List<ScopeSourceItem> = emptyList(),
) {
val isAll: Boolean
get() = groups.isEmpty() && sources.isEmpty()
}
data class ScopeSourceItem(
val name: String,
val url: String,
)
private data class SerializedSearchScope(
val type: String = "",
val groups: List<String> = emptyList(),
val sources: List<ScopeSourceItem> = emptyList(),
)
companion object {
fun encodeGroups(groups: List<String>): String {
val selected = groups.filter { it.isNotBlank() }
return if (selected.isEmpty()) {
""
} else {
scopeGson.toJson(SerializedSearchScope(type = TYPE_GROUP, groups = selected))
}
}
fun encodeSources(sources: List<ScopeSourceItem>): String {
val selected = sources.filter { it.url.isNotBlank() }
return if (selected.isEmpty()) {
""
} else {
scopeGson.toJson(SerializedSearchScope(type = TYPE_SOURCE, sources = selected))
}
}
private fun parse(raw: String): ParsedSearchScope {
if (raw.isEmpty()) return ParsedSearchScope()
parseJson(raw)?.let {
return it
}
return parseLegacy(raw)
}
private fun parseJson(raw: String): ParsedSearchScope? {
val json = raw.trim()
if (!json.startsWith("{") || !json.endsWith("}")) return null
return scopeGson.fromJsonObject<SerializedSearchScope>(json).getOrNull()?.let { scope ->
when (scope.type) {
TYPE_SOURCE -> ParsedSearchScope(
sources = scope.sources.filter { it.url.isNotBlank() }
)
TYPE_GROUP -> ParsedSearchScope(
groups = scope.groups.filter { it.isNotBlank() }
)
else -> null
}
}
}
private fun parseLegacy(raw: String): ParsedSearchScope {
val rawItems = raw.split(",").filter { it.isNotBlank() }
val sourceItems = parseLegacySourceItems(rawItems)
if (rawItems.isNotEmpty() && sourceItems.size == rawItems.size) {
return ParsedSearchScope(sources = sourceItems)
}
return ParsedSearchScope(groups = raw.splitNotBlank(",").toList())
}
private fun parseLegacySourceItems(items: List<String>): List<ScopeSourceItem> {
return items.mapNotNull { item ->
val splitIndex = item.indexOf("::")
if (splitIndex <= 0 || splitIndex >= item.lastIndex) {
null
} else {
ScopeSourceItem(
name = item.substring(0, splitIndex),
url = item.substring(splitIndex + 2)
)
}
}
}
private const val TYPE_GROUP = "group"
private const val TYPE_SOURCE = "source"
private val scopeGson = GsonBuilder().disableHtmlEscaping().create()
}
}
@@ -634,18 +634,6 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener {
val paddingDisplayCutouts
get() = ReadConfig.paddingDisplayCutouts
var searchScope: String
get() = appCtx.getPrefString("searchScope") ?: ""
set(value) {
appCtx.putPrefString("searchScope", value)
}
var searchGroup: String
get() = appCtx.getPrefString("searchGroup") ?: ""
set(value) {
appCtx.putPrefString("searchGroup", value)
}
var pageTouchSlop: Int
get() = ReadConfig.pageTouchSlop
set(value) {
@@ -5,8 +5,8 @@ import io.legado.app.R
import io.legado.app.data.appDb
import io.legado.app.data.entities.BookSource
import io.legado.app.data.entities.BookSourcePart
import io.legado.app.help.config.AppConfig
import io.legado.app.utils.splitNotBlank
import io.legado.app.domain.model.BookSearchScope
import io.legado.app.domain.model.BookSearchScope.ScopeSourceItem
import splitties.init.appCtx
/**
@@ -15,14 +15,14 @@ import splitties.init.appCtx
@Suppress("unused")
data class SearchScope(private var scope: String) {
constructor(groups: List<String>) : this(groups.joinToString(","))
constructor(groups: List<String>) : this(BookSearchScope.encodeGroups(groups))
constructor(source: BookSource) : this(
encodeSourceToken(source.bookSourceName, source.bookSourceUrl)
encodeSourceScope(source.bookSourceName, source.bookSourceUrl)
)
constructor(source: BookSourcePart) : this(
encodeSourceToken(source.bookSourceName, source.bookSourceUrl)
encodeSourceScope(source.bookSourceName, source.bookSourceUrl)
)
override fun toString(): String {
@@ -34,43 +34,36 @@ data class SearchScope(private var scope: String) {
fun update(scope: String, postValue: Boolean = true) {
this.scope = scope
if (postValue) stateLiveData.postValue(scope)
save()
}
fun update(groups: List<String>) {
scope = groups.joinToString(",")
scope = BookSearchScope.encodeGroups(groups)
stateLiveData.postValue(scope)
save()
}
fun update(source: BookSource) {
scope = encodeSourceToken(source.bookSourceName, source.bookSourceUrl)
scope = encodeSourceScope(source.bookSourceName, source.bookSourceUrl)
stateLiveData.postValue(scope)
save()
}
fun update(source: BookSourcePart) {
scope = encodeSourceToken(source.bookSourceName, source.bookSourceUrl)
scope = encodeSourceScope(source.bookSourceName, source.bookSourceUrl)
stateLiveData.postValue(scope)
save()
}
fun updateSources(sources: List<BookSourcePart>) {
scope = encodeSourceScope(sources)
stateLiveData.postValue(scope)
save()
}
fun isSource(): Boolean {
val items = scopeItems()
if (items.isEmpty()) return false
return parseSourceItems(items).size == items.size
return parsedScope().isSource
}
val display: String
get() {
if (isSource()) {
val sourceNames = parseSourceItems().map { it.name }
val sourceNames = parsedScope().sourceNames
if (sourceNames.isEmpty()) return appCtx.getString(R.string.all_source)
return sourceNames.joinToString(",")
}
@@ -87,40 +80,26 @@ data class SearchScope(private var scope: String) {
get() {
val list = arrayListOf<String>()
if (isSource()) {
parseSourceItems().forEach {
list.add(it.name)
}
list.addAll(parsedScope().sourceNames)
} else {
scopeItems().forEach {
list.add(it)
}
list.addAll(parsedScope().groupNames)
}
return list
}
val sourceUrls: List<String>
get() = parseSourceItems().map { it.url }
get() = parsedScope().sourceUrls
fun remove(scope: String) {
if (isSource()) {
val sourceItems = parseSourceItems().filterNot {
val sourceItems = sourceItems().filterNot {
it.name == scope || it.url == scope
}
this.scope = sourceItems.joinToString(",") { "${it.name}::${it.url}" }
this.scope = BookSearchScope.encodeSources(sourceItems)
} else {
val stringBuilder = StringBuilder()
scopeItems().forEach {
if (it != scope) {
if (stringBuilder.isNotEmpty()) {
stringBuilder.append(",")
}
stringBuilder.append(it)
}
}
this.scope = stringBuilder.toString()
this.scope = BookSearchScope.encodeGroups(displayNames.filterNot { it == scope })
}
stateLiveData.postValue(this.scope)
save()
}
/**
@@ -132,13 +111,13 @@ data class SearchScope(private var scope: String) {
list.addAll(appDb.bookSourceDao.allEnabledPart)
} else {
if (isSource()) {
parseSourceItems().forEach { sourceItem ->
sourceItems().forEach { sourceItem ->
appDb.bookSourceDao.getBookSourcePart(sourceItem.url)?.let { source ->
list.add(source)
}
}
} else {
val oldScope = scopeItems()
val oldScope = parsedScope().groupNames
val newScope = oldScope.filter {
val bookSources = appDb.bookSourceDao.getEnabledPartByGroup(it)
list.addAll(bookSources)
@@ -163,54 +142,38 @@ data class SearchScope(private var scope: String) {
}
fun isAll(): Boolean {
return scope.isEmpty()
return parsedScope().isAll
}
fun save() {
AppConfig.searchScope = scope
if (isAll() || isSource() || scope.contains(",")) {
AppConfig.searchGroup = ""
} else {
AppConfig.searchGroup = scope
private fun parsedScope(): BookSearchScope = BookSearchScope(scope)
private fun sourceItems(): List<ScopeSourceItem> {
val parsed = parsedScope()
return parsed.sourceNames.zip(parsed.sourceUrls) { name, url ->
ScopeSourceItem(name, url)
}
}
private fun scopeItems(): List<String> = scope.splitNotBlank(",").toList()
private fun parseSourceItems(
items: List<String> = scopeItems()
): List<ScopeSourceItem> {
return items.mapNotNull { item ->
val splitIndex = item.indexOf("::")
if (splitIndex <= 0 || splitIndex >= item.lastIndex) {
null
} else {
ScopeSourceItem(
name = item.substring(0, splitIndex),
url = item.substring(splitIndex + 2)
)
}
}
}
private data class ScopeSourceItem(
val name: String,
val url: String,
)
companion object {
private fun sanitizeSourceName(name: String): String {
return name.replace(":", "").replace(",", "")
}
private fun encodeSourceToken(name: String, url: String): String {
return "${sanitizeSourceName(name)}::${url}"
private fun encodeSourceScope(name: String, url: String): String {
return BookSearchScope.encodeSources(
listOf(ScopeSourceItem(sanitizeSourceName(name), url))
)
}
private fun encodeSourceScope(sources: List<BookSourcePart>): String {
return sources.joinToString(",") { source ->
encodeSourceToken(source.bookSourceName, source.bookSourceUrl)
}
return BookSearchScope.encodeSources(
sources.map { source ->
ScopeSourceItem(
name = sanitizeSourceName(source.bookSourceName),
url = source.bookSourceUrl
)
}
)
}
}
@@ -17,7 +17,6 @@ import io.legado.app.domain.usecase.ExploreBooksUseCase
import io.legado.app.domain.usecase.ResolveBookShelfStateUseCase
import io.legado.app.domain.usecase.SearchBooksUseCase
import io.legado.app.domain.usecase.SearchRunEvent
import io.legado.app.help.config.AppConfig
import io.legado.app.ui.config.otherConfig.OtherConfig
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentSetOf
@@ -69,10 +68,10 @@ class SearchViewModel(
private val _uiState = MutableStateFlow(
SearchUiState(
scopeDisplay = SearchScope(AppConfig.searchScope).display,
scopeDisplayNames = SearchScope(AppConfig.searchScope).displayNames.toImmutableList(),
isAllScope = SearchScope(AppConfig.searchScope).isAll(),
isSourceScope = SearchScope(AppConfig.searchScope).isSource(),
scopeDisplay = SearchScope("").display,
scopeDisplayNames = SearchScope("").displayNames.toImmutableList(),
isAllScope = SearchScope("").isAll(),
isSourceScope = SearchScope("").isSource(),
)
)
val uiState = _uiState.asStateFlow()
@@ -82,7 +81,9 @@ class SearchViewModel(
private val queryFlow = MutableStateFlow("")
private val bookshelfKeys = MutableStateFlow<Set<BookShelfKey>>(emptySet())
private val searchScope = SearchScope(AppConfig.searchScope)
private var persistedSearchScopeRaw = ""
private var hasTemporaryScope = false
private val searchScope = SearchScope("")
private val searchControl = BookSearchControl()
private val searchResultBooks = LinkedHashMap<SearchResultKey, SearchBook>()
@@ -93,6 +94,7 @@ class SearchViewModel(
init {
syncScopeState()
observeSearchScope()
observeEnabledGroups()
observeEnabledSources()
observeBookshelf()
@@ -205,6 +207,7 @@ class SearchViewModel(
SearchIntent.SelectAllScope -> {
val oldScope = searchScope.toString()
searchScope.update("")
persistSearchScope()
syncScopeState(restartSearch = true, oldScope = oldScope)
}
@@ -213,6 +216,7 @@ class SearchViewModel(
is SearchIntent.RemoveScopeItem -> {
val oldScope = searchScope.toString()
searchScope.remove(intent.scopeName)
persistSearchScope()
syncScopeState(restartSearch = true, oldScope = oldScope)
}
@@ -313,10 +317,15 @@ class SearchViewModel(
}
private fun initialize(key: String?, scopeRaw: String?) {
scopeRaw?.let {
searchScope.update(it, postValue = false)
if (scopeRaw != null) {
hasTemporaryScope = true
searchScope.update(scopeRaw, postValue = false)
syncScopeState()
} else if (hasTemporaryScope) {
hasTemporaryScope = false
searchScope.update(persistedSearchScopeRaw, postValue = false)
syncScopeState()
}
syncScopeState()
// When the ViewModel already holds a non-empty committed query,
// it means a search session is in progress or completed.
@@ -613,6 +622,7 @@ class SearchViewModel(
selected.add(groupName)
}
searchScope.update(selected.toList())
persistSearchScope()
syncScopeState(restartSearch = true, oldScope = oldScope)
}
@@ -638,6 +648,7 @@ class SearchViewModel(
}
searchScope.updateSources(selectedSources)
}
persistSearchScope()
syncScopeState(restartSearch = true, oldScope = oldScope)
}
@@ -653,6 +664,7 @@ class SearchViewModel(
}
} else {
searchScope.update("")
persistSearchScope()
syncScopeState()
}
@@ -685,6 +697,32 @@ class SearchViewModel(
}
}
private fun observeSearchScope() {
viewModelScope.launch {
localPreferencesRepository
.getPreference(LocalPreferencesKeys.SEARCH_SCOPE, "")
.distinctUntilChanged()
.collect { scopeRaw ->
persistedSearchScopeRaw = scopeRaw
if (!hasTemporaryScope && scopeRaw != searchScope.toString()) {
searchScope.update(scopeRaw, postValue = false)
syncScopeState()
}
}
}
}
private fun persistSearchScope() {
hasTemporaryScope = false
persistedSearchScopeRaw = searchScope.toString()
viewModelScope.launch {
localPreferencesRepository.updatePreference(
LocalPreferencesKeys.SEARCH_SCOPE,
searchScope.toString()
)
}
}
private fun List<SearchBook>.toSearchResultItems(
shelf: Set<BookShelfKey>
): List<SearchResultItemUi> {
@@ -15,7 +15,6 @@ import io.legado.app.domain.usecase.BookSearchControl
import io.legado.app.domain.usecase.BookSearchRequest
import io.legado.app.domain.usecase.SearchBooksUseCase
import io.legado.app.domain.usecase.SearchRunEvent
import io.legado.app.help.config.AppConfig
import io.legado.app.ui.config.otherConfig.OtherConfig
import io.legado.app.utils.GSON
import io.legado.app.utils.fromJsonObject
@@ -82,7 +81,11 @@ class BookSearchWebSocket(private val session: DefaultWebSocketServerSession) :
BookSearchRequest(
keyword = key,
page = 1,
scope = BookSearchScope(AppConfig.searchScope),
scope = BookSearchScope(
localPreferencesRepository
.getPreference(LocalPreferencesKeys.SEARCH_SCOPE, "")
.first()
),
matchMode = MatchMode.of(
localPreferencesRepository
.getPreference(