From e7ec672c8b4d52c734311177544fae5406781e16 Mon Sep 17 00:00:00 2001 From: NGB-Was-Taken <76197326+NGB-Was-Taken@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:05:27 +0545 Subject: [PATCH] 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 --- CHANGELOG.md | 4 + .../tachiyomi/ui/library/LibraryItem.kt | 57 +------ .../tachiyomi/ui/library/LibraryViewModel.kt | 14 +- .../feature/library/QueryNodeExtensions.kt | 146 ++++++++++++++++++ .../library/model/search/LibrarySearchAst.kt | 92 +++++++++++ .../model/search/LibrarySearchLexer.kt | 93 +++++++++++ .../model/search/LibrarySearchParser.kt | 79 ++++++++++ 7 files changed, 429 insertions(+), 56 deletions(-) create mode 100644 app/src/main/java/mihon/feature/library/QueryNodeExtensions.kt create mode 100644 domain/src/main/java/mihon/domain/library/model/search/LibrarySearchAst.kt create mode 100644 domain/src/main/java/mihon/domain/library/model/search/LibrarySearchLexer.kt create mode 100644 domain/src/main/java/mihon/domain/library/model/search/LibrarySearchParser.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index b60399353..36c295b5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,10 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co - Detect Shizuku with permission check ([@Small-Ku](https://github.com/Small-Ku)) ([#3565](https://github.com/mihonapp/mihon/pull/3565)) ### Improved +- Library search ([@NGB-Was-Taken](https://github.com/NGB-Was-Taken)) ([#3554](https://github.com/mihonapp/mihon/pull/3554)) + - Support logical operators (`&&`, `||`, `-`), implicit `AND` separators, and nested expressions with `()` + - Add comparison operators (`>`, `>=`, `<`, `<=`, `=`) for `id`, `added`, `fetchinterval` / `fi`, `nextupdate` / `nu`, `unread`, `read`, and `total` (Uses `yyyy-MM-dd` dates for `added` and `nextupdate`) + - Support field-specific prefixes (`title`, `author`, `artist`, `description` / `desc`, `genre` / `tag`, `source` / `src`, `source_id` / `src_id`, `notes` / `note`, `language` / `lang`) and empty field matching (`""`, `''`) - Always use Private installer to update privately installed extensions ([@MajorTanya](https://github.com/MajorTanya)) ([#3614](https://github.com/mihonapp/mihon/pull/3614)) ### Fixed diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryItem.kt index fdde930be..b9173096a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryItem.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryItem.kt @@ -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, diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryViewModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryViewModel.kt index 0301f4a11..a52f2fb18 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryViewModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryViewModel.kt @@ -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, val initialSelection: List>, ) : Dialog + data class DeleteManga(val manga: List) : Dialog } diff --git a/app/src/main/java/mihon/feature/library/QueryNodeExtensions.kt b/app/src/main/java/mihon/feature/library/QueryNodeExtensions.kt new file mode 100644 index 000000000..af49b7bc4 --- /dev/null +++ b/app/src/main/java/mihon/feature/library/QueryNodeExtensions.kt @@ -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 +} diff --git a/domain/src/main/java/mihon/domain/library/model/search/LibrarySearchAst.kt b/domain/src/main/java/mihon/domain/library/model/search/LibrarySearchAst.kt new file mode 100644 index 000000000..29db0b4f5 --- /dev/null +++ b/domain/src/main/java/mihon/domain/library/model/search/LibrarySearchAst.kt @@ -0,0 +1,92 @@ +package mihon.domain.library.model.search + +enum class MangaField(vararg val aliases: String, val fieldOnly: Boolean = false) { + TITLE("title"), + AUTHOR("author"), + ARTIST("artist"), + DESCRIPTION("description", "desc"), + GENRE("genre", "tag"), + SOURCE("source", "src"), + NOTES("notes", "note"), + LANGUAGE("language", "lang", fieldOnly = true), + SOURCE_ID("source_id", "sourceid", "src_id", "srcid", fieldOnly = true), + ; + + companion object { + private val lookup = entries.flatMap { field -> + field.aliases.map { it.lowercase() to field } + }.toMap() + + fun fromString(value: String): MangaField? = lookup[value.lowercase()] + } +} + +enum class ComparisonField(vararg val aliases: String) { + ID("id"), + DATE_ADDED("added"), + FETCH_INTERVAL("fetchinterval", "fi"), + NEXT_UPDATE("nextupdate", "nu"), + UNREAD("unread"), + READ("read"), + TOTAL("total"), + ; + + companion object { + private val lookup = entries.flatMap { field -> + field.aliases.map { it.lowercase() to field } + }.toMap() + + fun fromString(value: String): ComparisonField? = lookup[value.lowercase()] + } +} + +enum class Comparator(val symbol: String) { + GTE(">="), + LTE("<="), + GT(">"), + LT("<"), + EQ("="), + ; + + fun > apply(a: T, b: T): Boolean = when (this) { + GTE -> a >= b + LTE -> a <= b + GT -> a > b + LT -> a < b + EQ -> a == b + } + + companion object { + private val lookup = entries.associateBy { it.symbol } + + fun fromString(value: String): Comparator? = lookup[value] + } +} + +sealed interface QueryNode { + companion object { + fun from(query: String): QueryNode { + val tokens = LibrarySearchLexer.tokenize(query) + return LibrarySearchParser(tokens).parse() + } + } +} + +data class AndNode(val children: List) : QueryNode + +data class OrNode(val children: List) : QueryNode + +data class NotNode(val child: QueryNode) : QueryNode + +object EmptyQueryNode : QueryNode + +data class GeneralQueryNode(val value: String, val negated: Boolean) : QueryNode + +data class FieldQueryNode(val field: MangaField, val value: String, val negated: Boolean) : QueryNode + +data class ComparisonQueryNode( + val field: ComparisonField, + val value: String, + val queryComparator: Comparator, + val negated: Boolean, +) : QueryNode diff --git a/domain/src/main/java/mihon/domain/library/model/search/LibrarySearchLexer.kt b/domain/src/main/java/mihon/domain/library/model/search/LibrarySearchLexer.kt new file mode 100644 index 000000000..ab4d40405 --- /dev/null +++ b/domain/src/main/java/mihon/domain/library/model/search/LibrarySearchLexer.kt @@ -0,0 +1,93 @@ +package mihon.domain.library.model.search + +import kotlin.text.get + +object LibrarySearchLexer { + + private val regex = Regex( + """ + # Operators + (? \( )| + (? \) )| + (? -(?![\s,]) )| + (? \|\| )| + (? && )| + + # Comparison fields + (? [a-zA-Z_][a-zA-Z0-9_]* ) + (? >=|<=|>|<|= ) + (?: " (? [^"]* ) " | ' (? [^']* ) ' | (? [^\s,()]+ ))| + + # Key-Value fields + (? [a-zA-Z_][a-zA-Z0-9_]* ) : + (?: " (? [^"]* ) " | ' (? [^']* ) ' | (? [^\s,()]+ ))| + + # General catch-all + (?: " (? [^"]* ) " | ' (? [^']* ) ' | (? [^\s,()]+ ))| + + # Seperator + (? [\s,]+ ) + """.trimIndent(), + RegexOption.COMMENTS, + ) + + sealed interface Token { + data object LParen : Token + data object RParen : Token + data object And : Token + data object Or : Token + data object Not : Token + data class CompField(val field: String, val comparator: String, val value: String) : Token + data class Field(val field: String, val value: String) : Token + data class General(val value: String) : Token + } + + fun tokenize(input: String): List { + val tokens = mutableListOf() + val matches = regex.findAll(input) + + for (match in matches) { + val groups = match.groups + when { + groups["Separator"] != null -> continue + groups["AND"] != null -> tokens.add(Token.And) + groups["OR"] != null -> tokens.add(Token.Or) + groups["NOT"] != null -> tokens.add(Token.Not) + groups["LParen"] != null -> tokens.add(Token.LParen) + groups["RParen"] != null -> tokens.add(Token.RParen) + + groups["CompField"] != null -> { + tokens.add( + Token.CompField( + field = groups["CompField"]!!.value, + comparator = groups["Comparator"]!!.value, + value = groups["CompValQuoted"]?.value + ?: groups["CompValSingleQuoted"]?.value + ?: groups["CompVal"]!!.value, + ), + ) + } + + groups["Field"] != null -> { + tokens.add( + Token.Field( + field = groups["Field"]!!.value, + value = groups["FieldValQuoted"]?.value + ?: groups["FieldValSingleQuoted"]?.value + ?: groups["FieldVal"]!!.value, + ), + ) + } + + else -> { + val value = groups["GeneralQuoted"]?.value + ?: groups["GeneralSingleQuoted"]?.value + ?: groups["General"]!!.value + tokens.add(Token.General(value)) + } + } + } + + return tokens + } +} diff --git a/domain/src/main/java/mihon/domain/library/model/search/LibrarySearchParser.kt b/domain/src/main/java/mihon/domain/library/model/search/LibrarySearchParser.kt new file mode 100644 index 000000000..f5d5a1918 --- /dev/null +++ b/domain/src/main/java/mihon/domain/library/model/search/LibrarySearchParser.kt @@ -0,0 +1,79 @@ +package mihon.domain.library.model.search + +class LibrarySearchParser(private val tokens: List) { + private var index = 0 + + private fun peek(): LibrarySearchLexer.Token? = tokens.getOrNull(index) + private fun advance(): LibrarySearchLexer.Token? = tokens.getOrNull(index++) + + fun parse(): QueryNode { + if (tokens.isEmpty()) return AndNode(emptyList()) + return parseOr() + } + + private fun parseOr(): QueryNode { + val nodes = mutableListOf() + nodes.add(parseAnd()) + while (peek() is LibrarySearchLexer.Token.Or) { + advance() + nodes.add(parseAnd()) + } + return if (nodes.size == 1) nodes.first() else OrNode(nodes) + } + + private fun parseAnd(): QueryNode { + val nodes = mutableListOf() + + while (index < tokens.size && peek() !is LibrarySearchLexer.Token.Or && + peek() !is LibrarySearchLexer.Token.RParen + ) { + if (peek() is LibrarySearchLexer.Token.And) { + advance() + } + nodes.add(parseTerm()) + } + + val filteredNodes = nodes.filter { it !is EmptyQueryNode } + if (filteredNodes.isEmpty()) return EmptyQueryNode + return if (filteredNodes.size == 1) filteredNodes.first() else AndNode(filteredNodes) + } + + private fun parseTerm(): QueryNode { + var negated = false + + while (peek() is LibrarySearchLexer.Token.Not) { + advance() + negated = !negated + } + + if (peek() is LibrarySearchLexer.Token.LParen) { + advance() + + val subTree = parseOr() + + if (peek() is LibrarySearchLexer.Token.RParen) { + advance() + } + + if (negated) { + return NotNode(subTree) + } + return subTree + } + + return when (val nextToken = advance()) { + is LibrarySearchLexer.Token.General -> GeneralQueryNode(nextToken.value, negated) + is LibrarySearchLexer.Token.Field -> { + MangaField.fromString(nextToken.field)?.let { + FieldQueryNode(it, nextToken.value, negated) + } ?: GeneralQueryNode("${nextToken.field}:${nextToken.value}", negated) + } + is LibrarySearchLexer.Token.CompField -> { + ComparisonField.fromString(nextToken.field)?.let { + ComparisonQueryNode(it, nextToken.value, Comparator.fromString(nextToken.comparator)!!, negated) + } ?: GeneralQueryNode("${nextToken.field}${nextToken.comparator}${nextToken.value}", negated) + } + else -> EmptyQueryNode + } + } +}