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:
@@ -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))
|
- Detect Shizuku with permission check ([@Small-Ku](https://github.com/Small-Ku)) ([#3565](https://github.com/mihonapp/mihon/pull/3565))
|
||||||
|
|
||||||
### Improved
|
### 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))
|
- Always use Private installer to update privately installed extensions ([@MajorTanya](https://github.com/MajorTanya)) ([#3614](https://github.com/mihonapp/mihon/pull/3614))
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -1,71 +1,18 @@
|
|||||||
package eu.kanade.tachiyomi.ui.library
|
package eu.kanade.tachiyomi.ui.library
|
||||||
|
|
||||||
import eu.kanade.tachiyomi.source.getNameForMangaInfo
|
|
||||||
import tachiyomi.domain.library.model.LibraryManga
|
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(
|
data class LibraryItem(
|
||||||
val libraryManga: LibraryManga,
|
val libraryManga: LibraryManga,
|
||||||
val downloadCount: Int,
|
val downloadCount: Int,
|
||||||
val unreadCount: Long,
|
val unreadCount: Long,
|
||||||
val isLocal: Boolean,
|
val isLocal: Boolean,
|
||||||
|
val sourceName: String,
|
||||||
|
val sourceLanguage: String,
|
||||||
val badges: Badges,
|
val badges: Badges,
|
||||||
) {
|
) {
|
||||||
val id: Long = libraryManga.id
|
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(
|
data class Badges(
|
||||||
val downloadCount: Int,
|
val downloadCount: Int,
|
||||||
val unreadCount: Long,
|
val unreadCount: Long,
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ import kotlinx.coroutines.flow.update
|
|||||||
import kotlinx.coroutines.flow.updateAndGet
|
import kotlinx.coroutines.flow.updateAndGet
|
||||||
import mihon.core.common.utils.mutate
|
import mihon.core.common.utils.mutate
|
||||||
import mihon.core.viewmodel.StateViewModel
|
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.CheckboxState
|
||||||
import tachiyomi.core.common.preference.TriState
|
import tachiyomi.core.common.preference.TriState
|
||||||
import tachiyomi.core.common.util.lang.compareToWithCollator
|
import tachiyomi.core.common.util.lang.compareToWithCollator
|
||||||
@@ -100,7 +102,14 @@ class LibraryViewModel(
|
|||||||
val showSystemCategory = favorites.any { it.libraryManga.categories.contains(0) }
|
val showSystemCategory = favorites.any { it.libraryManga.categories.contains(0) }
|
||||||
val filteredFavorites = favorites
|
val filteredFavorites = favorites
|
||||||
.applyFilters(tracksMap, trackingFilters, itemPreferences)
|
.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(
|
LibraryData(
|
||||||
isInitialized = true,
|
isInitialized = true,
|
||||||
@@ -389,6 +398,8 @@ class LibraryViewModel(
|
|||||||
downloadCount = downloadManager.getDownloadCount(manga.manga),
|
downloadCount = downloadManager.getDownloadCount(manga.manga),
|
||||||
unreadCount = manga.unreadCount,
|
unreadCount = manga.unreadCount,
|
||||||
isLocal = manga.manga.isLocal(),
|
isLocal = manga.manga.isLocal(),
|
||||||
|
sourceName = sourceManager.getOrStub(manga.manga.source).name.lowercase(),
|
||||||
|
sourceLanguage = sourceManager.getOrStub(manga.manga.source).lang,
|
||||||
badges = LibraryItem.Badges(
|
badges = LibraryItem.Badges(
|
||||||
downloadCount = if (preferences.downloadBadge) {
|
downloadCount = if (preferences.downloadBadge) {
|
||||||
downloadManager.getDownloadCount(manga.manga)
|
downloadManager.getDownloadCount(manga.manga)
|
||||||
@@ -727,6 +738,7 @@ class LibraryViewModel(
|
|||||||
val manga: List<Manga>,
|
val manga: List<Manga>,
|
||||||
val initialSelection: List<CheckboxState<Category>>,
|
val initialSelection: List<CheckboxState<Category>>,
|
||||||
) : Dialog
|
) : Dialog
|
||||||
|
|
||||||
data class DeleteManga(val manga: List<Manga>) : 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
|
||||||
|
}
|
||||||
@@ -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 <T : Comparable<T>> 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>) : QueryNode
|
||||||
|
|
||||||
|
data class OrNode(val children: List<QueryNode>) : 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
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package mihon.domain.library.model.search
|
||||||
|
|
||||||
|
import kotlin.text.get
|
||||||
|
|
||||||
|
object LibrarySearchLexer {
|
||||||
|
|
||||||
|
private val regex = Regex(
|
||||||
|
"""
|
||||||
|
# Operators
|
||||||
|
(?<LParen> \( )|
|
||||||
|
(?<RParen> \) )|
|
||||||
|
(?<NOT> -(?![\s,]) )|
|
||||||
|
(?<OR> \|\| )|
|
||||||
|
(?<AND> && )|
|
||||||
|
|
||||||
|
# Comparison fields
|
||||||
|
(?<CompField> [a-zA-Z_][a-zA-Z0-9_]* )
|
||||||
|
(?<Comparator> >=|<=|>|<|= )
|
||||||
|
(?: " (?<CompValQuoted> [^"]* ) " | ' (?<CompValSingleQuoted> [^']* ) ' | (?<CompVal> [^\s,()]+ ))|
|
||||||
|
|
||||||
|
# Key-Value fields
|
||||||
|
(?<Field> [a-zA-Z_][a-zA-Z0-9_]* ) :
|
||||||
|
(?: " (?<FieldValQuoted> [^"]* ) " | ' (?<FieldValSingleQuoted> [^']* ) ' | (?<FieldVal> [^\s,()]+ ))|
|
||||||
|
|
||||||
|
# General catch-all
|
||||||
|
(?: " (?<GeneralQuoted> [^"]* ) " | ' (?<GeneralSingleQuoted> [^']* ) ' | (?<General> [^\s,()]+ ))|
|
||||||
|
|
||||||
|
# Seperator
|
||||||
|
(?<Separator> [\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<Token> {
|
||||||
|
val tokens = mutableListOf<Token>()
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package mihon.domain.library.model.search
|
||||||
|
|
||||||
|
class LibrarySearchParser(private val tokens: List<LibrarySearchLexer.Token>) {
|
||||||
|
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<QueryNode>()
|
||||||
|
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<QueryNode>()
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user