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 { object LocalPreferencesKeys {
val SHOW_THEME_REFACTOR_TIP = booleanPreferencesKey("show_theme_refactor_tip") val SHOW_THEME_REFACTOR_TIP = booleanPreferencesKey("show_theme_refactor_tip")
val SEARCH_LAYOUT_MODE = intPreferencesKey("search_layout_mode") val SEARCH_LAYOUT_MODE = intPreferencesKey("search_layout_mode")
val SEARCH_SCOPE = stringPreferencesKey("search_scope")
val MATCH_MODE = intPreferencesKey("match_mode") val MATCH_MODE = intPreferencesKey("match_mode")
val EXPLORE_LAYOUT_MODE = intPreferencesKey("explore_layout_mode") val EXPLORE_LAYOUT_MODE = intPreferencesKey("explore_layout_mode")
val EXPLORE_LAYOUT_GRID_PORTRAIT = intPreferencesKey("explore_layout_grid_portrait") val EXPLORE_LAYOUT_GRID_PORTRAIT = intPreferencesKey("explore_layout_grid_portrait")
@@ -1,40 +1,130 @@
package io.legado.app.domain.model package io.legado.app.domain.model
import com.google.gson.GsonBuilder
import io.legado.app.utils.fromJsonObject
import io.legado.app.utils.splitNotBlank import io.legado.app.utils.splitNotBlank
data class BookSearchScope(val raw: String) { data class BookSearchScope(val raw: String) {
val items: List<String> private val parsed: ParsedSearchScope by lazy {
get() = raw.splitNotBlank(",").toList() parse(raw)
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 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 name: String,
val url: 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 val paddingDisplayCutouts
get() = ReadConfig.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 var pageTouchSlop: Int
get() = ReadConfig.pageTouchSlop get() = ReadConfig.pageTouchSlop
set(value) { set(value) {
@@ -5,8 +5,8 @@ import io.legado.app.R
import io.legado.app.data.appDb import io.legado.app.data.appDb
import io.legado.app.data.entities.BookSource import io.legado.app.data.entities.BookSource
import io.legado.app.data.entities.BookSourcePart import io.legado.app.data.entities.BookSourcePart
import io.legado.app.help.config.AppConfig import io.legado.app.domain.model.BookSearchScope
import io.legado.app.utils.splitNotBlank import io.legado.app.domain.model.BookSearchScope.ScopeSourceItem
import splitties.init.appCtx import splitties.init.appCtx
/** /**
@@ -15,14 +15,14 @@ import splitties.init.appCtx
@Suppress("unused") @Suppress("unused")
data class SearchScope(private var scope: String) { 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( constructor(source: BookSource) : this(
encodeSourceToken(source.bookSourceName, source.bookSourceUrl) encodeSourceScope(source.bookSourceName, source.bookSourceUrl)
) )
constructor(source: BookSourcePart) : this( constructor(source: BookSourcePart) : this(
encodeSourceToken(source.bookSourceName, source.bookSourceUrl) encodeSourceScope(source.bookSourceName, source.bookSourceUrl)
) )
override fun toString(): String { override fun toString(): String {
@@ -34,43 +34,36 @@ data class SearchScope(private var scope: String) {
fun update(scope: String, postValue: Boolean = true) { fun update(scope: String, postValue: Boolean = true) {
this.scope = scope this.scope = scope
if (postValue) stateLiveData.postValue(scope) if (postValue) stateLiveData.postValue(scope)
save()
} }
fun update(groups: List<String>) { fun update(groups: List<String>) {
scope = groups.joinToString(",") scope = BookSearchScope.encodeGroups(groups)
stateLiveData.postValue(scope) stateLiveData.postValue(scope)
save()
} }
fun update(source: BookSource) { fun update(source: BookSource) {
scope = encodeSourceToken(source.bookSourceName, source.bookSourceUrl) scope = encodeSourceScope(source.bookSourceName, source.bookSourceUrl)
stateLiveData.postValue(scope) stateLiveData.postValue(scope)
save()
} }
fun update(source: BookSourcePart) { fun update(source: BookSourcePart) {
scope = encodeSourceToken(source.bookSourceName, source.bookSourceUrl) scope = encodeSourceScope(source.bookSourceName, source.bookSourceUrl)
stateLiveData.postValue(scope) stateLiveData.postValue(scope)
save()
} }
fun updateSources(sources: List<BookSourcePart>) { fun updateSources(sources: List<BookSourcePart>) {
scope = encodeSourceScope(sources) scope = encodeSourceScope(sources)
stateLiveData.postValue(scope) stateLiveData.postValue(scope)
save()
} }
fun isSource(): Boolean { fun isSource(): Boolean {
val items = scopeItems() return parsedScope().isSource
if (items.isEmpty()) return false
return parseSourceItems(items).size == items.size
} }
val display: String val display: String
get() { get() {
if (isSource()) { if (isSource()) {
val sourceNames = parseSourceItems().map { it.name } val sourceNames = parsedScope().sourceNames
if (sourceNames.isEmpty()) return appCtx.getString(R.string.all_source) if (sourceNames.isEmpty()) return appCtx.getString(R.string.all_source)
return sourceNames.joinToString(",") return sourceNames.joinToString(",")
} }
@@ -87,40 +80,26 @@ data class SearchScope(private var scope: String) {
get() { get() {
val list = arrayListOf<String>() val list = arrayListOf<String>()
if (isSource()) { if (isSource()) {
parseSourceItems().forEach { list.addAll(parsedScope().sourceNames)
list.add(it.name)
}
} else { } else {
scopeItems().forEach { list.addAll(parsedScope().groupNames)
list.add(it)
}
} }
return list return list
} }
val sourceUrls: List<String> val sourceUrls: List<String>
get() = parseSourceItems().map { it.url } get() = parsedScope().sourceUrls
fun remove(scope: String) { fun remove(scope: String) {
if (isSource()) { if (isSource()) {
val sourceItems = parseSourceItems().filterNot { val sourceItems = sourceItems().filterNot {
it.name == scope || it.url == scope it.name == scope || it.url == scope
} }
this.scope = sourceItems.joinToString(",") { "${it.name}::${it.url}" } this.scope = BookSearchScope.encodeSources(sourceItems)
} else { } else {
val stringBuilder = StringBuilder() this.scope = BookSearchScope.encodeGroups(displayNames.filterNot { it == scope })
scopeItems().forEach {
if (it != scope) {
if (stringBuilder.isNotEmpty()) {
stringBuilder.append(",")
}
stringBuilder.append(it)
}
}
this.scope = stringBuilder.toString()
} }
stateLiveData.postValue(this.scope) stateLiveData.postValue(this.scope)
save()
} }
/** /**
@@ -132,13 +111,13 @@ data class SearchScope(private var scope: String) {
list.addAll(appDb.bookSourceDao.allEnabledPart) list.addAll(appDb.bookSourceDao.allEnabledPart)
} else { } else {
if (isSource()) { if (isSource()) {
parseSourceItems().forEach { sourceItem -> sourceItems().forEach { sourceItem ->
appDb.bookSourceDao.getBookSourcePart(sourceItem.url)?.let { source -> appDb.bookSourceDao.getBookSourcePart(sourceItem.url)?.let { source ->
list.add(source) list.add(source)
} }
} }
} else { } else {
val oldScope = scopeItems() val oldScope = parsedScope().groupNames
val newScope = oldScope.filter { val newScope = oldScope.filter {
val bookSources = appDb.bookSourceDao.getEnabledPartByGroup(it) val bookSources = appDb.bookSourceDao.getEnabledPartByGroup(it)
list.addAll(bookSources) list.addAll(bookSources)
@@ -163,54 +142,38 @@ data class SearchScope(private var scope: String) {
} }
fun isAll(): Boolean { fun isAll(): Boolean {
return scope.isEmpty() return parsedScope().isAll
} }
fun save() { private fun parsedScope(): BookSearchScope = BookSearchScope(scope)
AppConfig.searchScope = scope
if (isAll() || isSource() || scope.contains(",")) { private fun sourceItems(): List<ScopeSourceItem> {
AppConfig.searchGroup = "" val parsed = parsedScope()
} else { return parsed.sourceNames.zip(parsed.sourceUrls) { name, url ->
AppConfig.searchGroup = scope 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 { companion object {
private fun sanitizeSourceName(name: String): String { private fun sanitizeSourceName(name: String): String {
return name.replace(":", "").replace(",", "") return name.replace(":", "").replace(",", "")
} }
private fun encodeSourceToken(name: String, url: String): String { private fun encodeSourceScope(name: String, url: String): String {
return "${sanitizeSourceName(name)}::${url}" return BookSearchScope.encodeSources(
listOf(ScopeSourceItem(sanitizeSourceName(name), url))
)
} }
private fun encodeSourceScope(sources: List<BookSourcePart>): String { private fun encodeSourceScope(sources: List<BookSourcePart>): String {
return sources.joinToString(",") { source -> return BookSearchScope.encodeSources(
encodeSourceToken(source.bookSourceName, source.bookSourceUrl) 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.ResolveBookShelfStateUseCase
import io.legado.app.domain.usecase.SearchBooksUseCase import io.legado.app.domain.usecase.SearchBooksUseCase
import io.legado.app.domain.usecase.SearchRunEvent 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.ui.config.otherConfig.OtherConfig
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.persistentSetOf
@@ -69,10 +68,10 @@ class SearchViewModel(
private val _uiState = MutableStateFlow( private val _uiState = MutableStateFlow(
SearchUiState( SearchUiState(
scopeDisplay = SearchScope(AppConfig.searchScope).display, scopeDisplay = SearchScope("").display,
scopeDisplayNames = SearchScope(AppConfig.searchScope).displayNames.toImmutableList(), scopeDisplayNames = SearchScope("").displayNames.toImmutableList(),
isAllScope = SearchScope(AppConfig.searchScope).isAll(), isAllScope = SearchScope("").isAll(),
isSourceScope = SearchScope(AppConfig.searchScope).isSource(), isSourceScope = SearchScope("").isSource(),
) )
) )
val uiState = _uiState.asStateFlow() val uiState = _uiState.asStateFlow()
@@ -82,7 +81,9 @@ class SearchViewModel(
private val queryFlow = MutableStateFlow("") private val queryFlow = MutableStateFlow("")
private val bookshelfKeys = MutableStateFlow<Set<BookShelfKey>>(emptySet()) 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 searchControl = BookSearchControl()
private val searchResultBooks = LinkedHashMap<SearchResultKey, SearchBook>() private val searchResultBooks = LinkedHashMap<SearchResultKey, SearchBook>()
@@ -93,6 +94,7 @@ class SearchViewModel(
init { init {
syncScopeState() syncScopeState()
observeSearchScope()
observeEnabledGroups() observeEnabledGroups()
observeEnabledSources() observeEnabledSources()
observeBookshelf() observeBookshelf()
@@ -205,6 +207,7 @@ class SearchViewModel(
SearchIntent.SelectAllScope -> { SearchIntent.SelectAllScope -> {
val oldScope = searchScope.toString() val oldScope = searchScope.toString()
searchScope.update("") searchScope.update("")
persistSearchScope()
syncScopeState(restartSearch = true, oldScope = oldScope) syncScopeState(restartSearch = true, oldScope = oldScope)
} }
@@ -213,6 +216,7 @@ class SearchViewModel(
is SearchIntent.RemoveScopeItem -> { is SearchIntent.RemoveScopeItem -> {
val oldScope = searchScope.toString() val oldScope = searchScope.toString()
searchScope.remove(intent.scopeName) searchScope.remove(intent.scopeName)
persistSearchScope()
syncScopeState(restartSearch = true, oldScope = oldScope) syncScopeState(restartSearch = true, oldScope = oldScope)
} }
@@ -313,10 +317,15 @@ class SearchViewModel(
} }
private fun initialize(key: String?, scopeRaw: String?) { private fun initialize(key: String?, scopeRaw: String?) {
scopeRaw?.let { if (scopeRaw != null) {
searchScope.update(it, postValue = false) 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, // When the ViewModel already holds a non-empty committed query,
// it means a search session is in progress or completed. // it means a search session is in progress or completed.
@@ -613,6 +622,7 @@ class SearchViewModel(
selected.add(groupName) selected.add(groupName)
} }
searchScope.update(selected.toList()) searchScope.update(selected.toList())
persistSearchScope()
syncScopeState(restartSearch = true, oldScope = oldScope) syncScopeState(restartSearch = true, oldScope = oldScope)
} }
@@ -638,6 +648,7 @@ class SearchViewModel(
} }
searchScope.updateSources(selectedSources) searchScope.updateSources(selectedSources)
} }
persistSearchScope()
syncScopeState(restartSearch = true, oldScope = oldScope) syncScopeState(restartSearch = true, oldScope = oldScope)
} }
@@ -653,6 +664,7 @@ class SearchViewModel(
} }
} else { } else {
searchScope.update("") searchScope.update("")
persistSearchScope()
syncScopeState() 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( private fun List<SearchBook>.toSearchResultItems(
shelf: Set<BookShelfKey> shelf: Set<BookShelfKey>
): List<SearchResultItemUi> { ): 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.BookSearchRequest
import io.legado.app.domain.usecase.SearchBooksUseCase import io.legado.app.domain.usecase.SearchBooksUseCase
import io.legado.app.domain.usecase.SearchRunEvent 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.ui.config.otherConfig.OtherConfig
import io.legado.app.utils.GSON import io.legado.app.utils.GSON
import io.legado.app.utils.fromJsonObject import io.legado.app.utils.fromJsonObject
@@ -82,7 +81,11 @@ class BookSearchWebSocket(private val session: DefaultWebSocketServerSession) :
BookSearchRequest( BookSearchRequest(
keyword = key, keyword = key,
page = 1, page = 1,
scope = BookSearchScope(AppConfig.searchScope), scope = BookSearchScope(
localPreferencesRepository
.getPreference(LocalPreferencesKeys.SEARCH_SCOPE, "")
.first()
),
matchMode = MatchMode.of( matchMode = MatchMode.of(
localPreferencesRepository localPreferencesRepository
.getPreference( .getPreference(