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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
package mihon.feature.library
|
||||
|
||||
import eu.kanade.tachiyomi.ui.library.LibraryItem
|
||||
import mihon.domain.library.model.search.AndNode
|
||||
import mihon.domain.library.model.search.ComparisonField
|
||||
import mihon.domain.library.model.search.ComparisonQueryNode
|
||||
import mihon.domain.library.model.search.EmptyQueryNode
|
||||
import mihon.domain.library.model.search.FieldQueryNode
|
||||
import mihon.domain.library.model.search.GeneralQueryNode
|
||||
import mihon.domain.library.model.search.MangaField
|
||||
import mihon.domain.library.model.search.NotNode
|
||||
import mihon.domain.library.model.search.OrNode
|
||||
import mihon.domain.library.model.search.QueryNode
|
||||
import tachiyomi.source.local.LocalSource
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import kotlin.math.abs
|
||||
|
||||
fun QueryNode.matches(item: LibraryItem): Boolean {
|
||||
return when (this) {
|
||||
is AndNode -> children.all { it.matches(item) }
|
||||
is OrNode -> children.any { it.matches(item) }
|
||||
is NotNode -> !child.matches(item)
|
||||
is EmptyQueryNode -> true
|
||||
is GeneralQueryNode -> matches(item)
|
||||
is FieldQueryNode -> matches(item)
|
||||
is ComparisonQueryNode -> matches(item)
|
||||
}
|
||||
}
|
||||
|
||||
private fun GeneralQueryNode.matches(item: LibraryItem): Boolean {
|
||||
val manga = item.libraryManga.manga
|
||||
|
||||
// Use when so each added field has to be handled explicitly
|
||||
val match = MangaField.entries.any { field ->
|
||||
if (field.fieldOnly) return@any false
|
||||
|
||||
when (field) {
|
||||
MangaField.TITLE -> manga.title.contains(value, ignoreCase = true)
|
||||
MangaField.AUTHOR -> manga.author?.contains(value, ignoreCase = true) ?: false
|
||||
MangaField.ARTIST -> manga.artist?.contains(value, ignoreCase = true) ?: false
|
||||
MangaField.DESCRIPTION -> manga.description?.contains(value, ignoreCase = true) ?: false
|
||||
MangaField.GENRE -> manga.genre?.any { it.contains(value, ignoreCase = true) } ?: false
|
||||
MangaField.SOURCE -> {
|
||||
item.sourceName.contains(value, ignoreCase = true) ||
|
||||
(value.equals("local", ignoreCase = true) && manga.source == LocalSource.ID)
|
||||
}
|
||||
MangaField.NOTES -> manga.notes.contains(value, ignoreCase = true)
|
||||
|
||||
// field-only queries; unreachable; added here to make `when` exhaustive
|
||||
MangaField.LANGUAGE, MangaField.SOURCE_ID -> error("How did we get here?")
|
||||
}
|
||||
}
|
||||
return if (negated) !match else match
|
||||
}
|
||||
|
||||
private fun FieldQueryNode.matches(item: LibraryItem): Boolean {
|
||||
val manga = item.libraryManga.manga
|
||||
|
||||
val match = when (field) {
|
||||
MangaField.GENRE -> {
|
||||
if (value.isEmpty()) {
|
||||
manga.genre.isNullOrEmpty()
|
||||
} else {
|
||||
manga.genre?.any { it.contains(value, ignoreCase = true) } ?: false
|
||||
}
|
||||
}
|
||||
|
||||
MangaField.SOURCE -> {
|
||||
if (value.isEmpty()) {
|
||||
item.sourceName.isEmpty()
|
||||
} else {
|
||||
item.sourceName.contains(value, ignoreCase = true) ||
|
||||
(value.equals("local", ignoreCase = true) && manga.source == LocalSource.ID)
|
||||
}
|
||||
}
|
||||
|
||||
MangaField.SOURCE_ID -> {
|
||||
value.toLongOrNull()?.let { it == manga.source } ?: false
|
||||
}
|
||||
|
||||
else -> {
|
||||
val text = when (field) {
|
||||
MangaField.TITLE -> manga.title
|
||||
MangaField.AUTHOR -> manga.author
|
||||
MangaField.ARTIST -> manga.artist
|
||||
MangaField.DESCRIPTION -> manga.description
|
||||
MangaField.NOTES -> manga.notes
|
||||
MangaField.LANGUAGE -> item.sourceLanguage
|
||||
|
||||
// unreachable; added here to make `when` exhaustive
|
||||
MangaField.GENRE, MangaField.SOURCE, MangaField.SOURCE_ID -> error("How did we get here?")
|
||||
}
|
||||
|
||||
if (value.isEmpty()) {
|
||||
text.isNullOrEmpty()
|
||||
} else {
|
||||
text?.contains(value, ignoreCase = true) ?: false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return if (negated) !match else match
|
||||
}
|
||||
|
||||
private fun ComparisonQueryNode.matches(item: LibraryItem): Boolean {
|
||||
val manga = item.libraryManga.manga
|
||||
|
||||
fun compareDates(timestamp: Long, value: String): Boolean? {
|
||||
val inputDate = runCatching { LocalDate.parse(value) }.getOrNull() ?: return null
|
||||
val mangaDate = Instant.ofEpochMilli(timestamp).atZone(ZoneId.systemDefault()).toLocalDate()
|
||||
return queryComparator.apply(mangaDate, inputDate)
|
||||
}
|
||||
|
||||
val match = when (field) {
|
||||
ComparisonField.ID -> value.toLongOrNull()?.let { queryComparator.apply(manga.id, it) }
|
||||
|
||||
ComparisonField.DATE_ADDED -> compareDates(manga.dateAdded, value)
|
||||
|
||||
ComparisonField.FETCH_INTERVAL -> value.toIntOrNull()
|
||||
?.let { queryComparator.apply(abs(manga.fetchInterval), it) }
|
||||
|
||||
ComparisonField.NEXT_UPDATE -> compareDates(manga.nextUpdate, value)
|
||||
|
||||
ComparisonField.UNREAD -> {
|
||||
value.toLongOrNull()?.let {
|
||||
queryComparator.apply(item.unreadCount, it)
|
||||
}
|
||||
}
|
||||
|
||||
ComparisonField.READ -> {
|
||||
value.toLongOrNull()?.let {
|
||||
queryComparator.apply(item.libraryManga.readCount, it)
|
||||
}
|
||||
}
|
||||
|
||||
ComparisonField.TOTAL -> {
|
||||
value.toLongOrNull()?.let {
|
||||
queryComparator.apply(item.libraryManga.totalChapters, it)
|
||||
}
|
||||
}
|
||||
} ?: false
|
||||
|
||||
return if (negated) !match else match
|
||||
}
|
||||
Reference in New Issue
Block a user