Rework library search to use an AST-based approach (#3554)

* Rework library search to use an AST-based approach

* Spotless apply

* Move the filtering logic to the database

* Spotless apply

* Cleanup and separation of concerns

* Add `notes` to field and general search, and chapter read, total chapters to comparison search

* Handle searching for empty fields

* Add `language` as a field-only query option

* Clean up SQL clause generations

* Spotless apply

* Do not parse `-` as a negation when followed by a separator

* Revert moving filtering logic to database

* Changelog

* Use contains for genre instead of equals

* Add special handling for source name

* Add field-only query for source id

* Apply suggestions
This commit is contained in:
NGB-Was-Taken
2026-07-22 19:05:27 +05:45
committed by GitHub
parent 628cb58862
commit e7ec672c8b
7 changed files with 429 additions and 56 deletions
@@ -1,71 +1,18 @@
package eu.kanade.tachiyomi.ui.library
import eu.kanade.tachiyomi.source.getNameForMangaInfo
import tachiyomi.domain.library.model.LibraryManga
import tachiyomi.domain.source.service.SourceManager
import tachiyomi.source.local.LocalSource
private const val LOCAL_SOURCE_ID_ALIAS = "local"
data class LibraryItem(
val libraryManga: LibraryManga,
val downloadCount: Int,
val unreadCount: Long,
val isLocal: Boolean,
val sourceName: String,
val sourceLanguage: String,
val badges: Badges,
) {
val id: Long = libraryManga.id
/**
* Checks if a query matches the manga
*
* @param constraint the query to check.
* @return true if the manga matches the query, false otherwise.
*/
fun matches(constraint: String, sourceManager: SourceManager): Boolean {
val source = sourceManager.getOrStub(libraryManga.manga.source)
val sourceName by lazy { source.getNameForMangaInfo() }
if (constraint.startsWith("id:", true)) {
return id == constraint.substringAfter("id:").toLongOrNull()
} else if (constraint.startsWith("src:", true)) {
val querySource = constraint.substringAfter("src:")
return if (querySource.equals(LOCAL_SOURCE_ID_ALIAS, ignoreCase = true)) {
source.id == LocalSource.ID
} else {
source.id == querySource.toLongOrNull()
}
}
return libraryManga.manga.title.contains(constraint, true) ||
(libraryManga.manga.author?.contains(constraint, true) ?: false) ||
(libraryManga.manga.artist?.contains(constraint, true) ?: false) ||
(libraryManga.manga.description?.contains(constraint, true) ?: false) ||
constraint.split(",").map { it.trim() }.all { subconstraint ->
checkNegatableConstraint(subconstraint) {
sourceName.contains(it, true) ||
(libraryManga.manga.genre?.any { genre -> genre.equals(it, true) } ?: false)
}
}
}
/**
* Checks a predicate on a negatable constraint. If the constraint starts with a minus character,
* the minus is stripped and the result of the predicate is inverted.
*
* @param constraint the argument to the predicate. Inverts the predicate if it starts with '-'.
* @param predicate the check to be run against the constraint.
* @return !predicate(x) if constraint = "-x", otherwise predicate(constraint)
*/
private fun checkNegatableConstraint(
constraint: String,
predicate: (String) -> Boolean,
): Boolean {
return if (constraint.startsWith("-")) {
!predicate(constraint.substringAfter("-").trimStart())
} else {
predicate(constraint)
}
}
data class Badges(
val downloadCount: Int,
val unreadCount: Long,
@@ -36,6 +36,8 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.flow.updateAndGet
import mihon.core.common.utils.mutate
import mihon.core.viewmodel.StateViewModel
import mihon.domain.library.model.search.QueryNode
import mihon.feature.library.matches
import tachiyomi.core.common.preference.CheckboxState
import tachiyomi.core.common.preference.TriState
import tachiyomi.core.common.util.lang.compareToWithCollator
@@ -100,7 +102,14 @@ class LibraryViewModel(
val showSystemCategory = favorites.any { it.libraryManga.categories.contains(0) }
val filteredFavorites = favorites
.applyFilters(tracksMap, trackingFilters, itemPreferences)
.let { if (searchQuery == null) it else it.filter { m -> m.matches(searchQuery, sourceManager) } }
.let { libraryItems ->
if (searchQuery.isNullOrEmpty()) {
libraryItems
} else {
val queryNode = QueryNode.from(searchQuery)
libraryItems.filter { queryNode.matches(it) }
}
}
LibraryData(
isInitialized = true,
@@ -389,6 +398,8 @@ class LibraryViewModel(
downloadCount = downloadManager.getDownloadCount(manga.manga),
unreadCount = manga.unreadCount,
isLocal = manga.manga.isLocal(),
sourceName = sourceManager.getOrStub(manga.manga.source).name.lowercase(),
sourceLanguage = sourceManager.getOrStub(manga.manga.source).lang,
badges = LibraryItem.Badges(
downloadCount = if (preferences.downloadBadge) {
downloadManager.getDownloadCount(manga.manga)
@@ -727,6 +738,7 @@ class LibraryViewModel(
val manga: List<Manga>,
val initialSelection: List<CheckboxState<Category>>,
) : Dialog
data class DeleteManga(val manga: List<Manga>) : Dialog
}