From 663fedb81b404efabf50a15923c4e908911f4667 Mon Sep 17 00:00:00 2001 From: FooIbar <118464521+FooIbar@users.noreply.github.com> Date: Sun, 13 Jul 2025 20:18:20 +0800 Subject: [PATCH] Improve tag suggestion hit rate --- .../hippo/ehviewer/client/EhTagDatabase.kt | 81 +++++++++++++------ .../ehviewer/ui/screen/FavoritesScreen.kt | 2 +- .../ehviewer/ui/screen/GalleryListScreen.kt | 2 +- .../ehviewer/ui/screen/SearchBarScreen.kt | 35 ++++---- .../com/hippo/ehviewer/ui/tools/Dialog.kt | 4 +- 5 files changed, 81 insertions(+), 43 deletions(-) diff --git a/app/src/main/kotlin/com/hippo/ehviewer/client/EhTagDatabase.kt b/app/src/main/kotlin/com/hippo/ehviewer/client/EhTagDatabase.kt index 5650a6645..2c6e1cb53 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/client/EhTagDatabase.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/client/EhTagDatabase.kt @@ -65,45 +65,78 @@ object EhTagDatabase : CoroutineScope { fun getTranslation(prefix: String? = NAMESPACE_PREFIX, tag: String?): String? = tagGroups[prefix]?.get(tag)?.trim()?.ifEmpty { null } + // Copied from https://github.com/EhTagTranslation/EhSyringe/blob/c0cf88da04081072492829673aca34380b14dae6/src/plugin/suggest.ts#L37 + // TODO: Optimize + private val nsScore = mapOf( + TagNamespace.Other.prefix to 10f, + TagNamespace.Female.prefix to 9f, + TagNamespace.Male.prefix to 8.5f, + TagNamespace.Mixed.prefix to 8f, + TagNamespace.Parody.prefix to 3.3f, + TagNamespace.Character.prefix to 2.8f, + TagNamespace.Artist.prefix to 2.5f, + TagNamespace.Cosplayer.prefix to 2.4f, + TagNamespace.Group.prefix to 2.2f, + TagNamespace.Language.prefix to 2f, + ) + + data class Tag(val tag: String, val hint: String?, val score: Float) + context(ctx: Context) - fun suggestion(rawKeyword: String, expectTranslate: Boolean): Sequence> { - if (!initialized) return emptySequence() - val translate = expectTranslate && translatable + fun suggestion(rawKeyword: String, expectTranslate: Boolean): List { val keyword = PREFIXES.fold(rawKeyword) { kwd, pfx -> kwd.removePrefix(pfx) } + if (!initialized || keyword.isEmpty()) return emptyList() + val translate = expectTranslate && translatable val prefix = rawKeyword.dropLast(keyword.length) val ns = keyword.substringBefore(':') val tag = keyword.drop(ns.length + 1) val nsPrefix = TagNamespace.from(ns)?.prefix ?: ns val tags = tagGroups[nsPrefix.takeIf { tag.isNotEmpty() && it != NAMESPACE_PREFIX }] - fun suggestOnce(exactly: Boolean) = let { - fun lookup(tags: Map, keyword: String) = when { - exactly -> tags[keyword]?.let { t -> sequenceOf(keyword to t.takeIf { translate }) } ?: emptySequence() - translate -> tags.asSequence().filter { (tag, hint) -> - tag != keyword && (tag.containsIgnoreSpace(keyword) || hint.containsIgnoreSpace(keyword)) - }.map { (tag, hint) -> tag to hint } - else -> tags.keys.asSequence().filter { tag -> - tag != keyword && tag.containsIgnoreSpace(keyword) - }.map { tag -> tag to null } + return if (tags != null) { + lookup(tags, tag, translate).map { it.copy(tag = "$prefix$nsPrefix:${it.tag}") } + } else { + tagGroups.asSequence().flatMap { (nsPrefix, tags) -> + if (nsPrefix != NAMESPACE_PREFIX) { + lookup(tags, keyword, translate).map { it.copy(tag = "$prefix$nsPrefix:${it.tag}", score = it.score / (nsScore[nsPrefix] ?: 1f)) } + } else { + lookup(tags, keyword, translate).map { it.copy(tag = "$prefix${it.tag}:", score = 0f) } + } } - if (tags != null) { - lookup(tags, tag).map { (tag, hint) -> "$prefix$nsPrefix:$tag" to hint } + }.toMutableList().apply { sortBy { it.score } } + } + + private fun lookup(tags: Map, keyword: String, translate: Boolean) = if (translate) { + tags.asSequence().mapNotNull { (tag, hint) -> + val score = if (tag.startsWith(keyword, ignoreCase = true)) { + tag.length } else { - tagGroups.asSequence().flatMap { (nsPrefix, tags) -> - if (nsPrefix != NAMESPACE_PREFIX) { - lookup(tags, keyword).map { (tag, hint) -> "$prefix$nsPrefix:$tag" to hint } - } else { - lookup(tags, keyword).map { (ns, hint) -> "$prefix$ns:" to hint } + if (tag.indexOf(" $keyword", ignoreCase = true) != -1) { + tag.length * 2 + } else { + when (hint.indexOf(keyword, ignoreCase = true)) { + -1 -> 0 + 0 -> hint.length + else -> hint.length * 2 } } } + if (score != 0) Tag(tag, hint, score.toFloat()) else null + } + } else { + tags.keys.asSequence().mapNotNull { tag -> + val score = if (tag.startsWith(keyword, ignoreCase = true)) { + tag.length + } else { + if (tag.indexOf(" $keyword", ignoreCase = true) != -1) { + tag.length * 2 + } else { + 0 + } + } + if (score != 0) Tag(tag, null, score.toFloat()) else null } - return suggestOnce(true) + suggestOnce(false) } - private fun String.removeSpace(): String = replace(" ", "") - - private fun String.containsIgnoreSpace(other: String, ignoreCase: Boolean = true): Boolean = removeSpace().contains(other.removeSpace(), ignoreCase) - private fun metadata(context: Context): Array = context.resources.getStringArray(R.array.tag_translation_metadata) context(ctx: Context) diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/FavoritesScreen.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/FavoritesScreen.kt index 19c534656..5642949f9 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/FavoritesScreen.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/FavoritesScreen.kt @@ -186,7 +186,7 @@ fun AnimatedVisibilityScope.FavouritesScreen(navigator: DestinationsNavigator, v }, title = title, searchFieldHint = searchBarHint, - tagNamespace = !urlBuilder.isLocal, + localSearch = urlBuilder.isLocal, searchBarOffsetY = { searchBarOffsetY }, trailingIcon = { val sheetState = LocalSideSheetState.current diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/GalleryListScreen.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/GalleryListScreen.kt index 8acc1b83c..c07488318 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/GalleryListScreen.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/GalleryListScreen.kt @@ -481,7 +481,7 @@ fun AnimatedVisibilityScope.GalleryListScreen( GalleryPageUrlSuggestion(gid, pToken, page) } }, - tagNamespace = true, + localSearch = false, searchBarOffsetY = { searchBarOffsetY }, trailingIcon = { val sheetState = LocalSideSheetState.current diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/SearchBarScreen.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/SearchBarScreen.kt index dc6d8b0d0..dd2b5354e 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/SearchBarScreen.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/SearchBarScreen.kt @@ -111,7 +111,7 @@ fun SearchBarScreen( searchFieldHint: String, searchFieldState: TextFieldState = rememberTextFieldState(), suggestionProvider: SuggestionProvider? = null, - tagNamespace: Boolean = false, + localSearch: Boolean = true, searchBarOffsetY: () -> Int = { 0 }, trailingIcon: @Composable () -> Unit = {}, filter: @Composable (() -> Unit)? = null, @@ -130,10 +130,18 @@ fun SearchBarScreen( ) : Suggestion() { override fun onClick() { val query = searchFieldState.text.toString() - var keywords = query.substringBeforeLast(' ', "") - if (keywords.isNotEmpty()) keywords += ' ' - keywords += if (tagNamespace) wrapTagKeyword(keyword) else keyword.substringAfter(':') - if (!keywords.endsWith(':')) keywords += ' ' + val (index, keyword) = if (localSearch) { + query.lastIndexOf(' ') to + "${keyword.substringAfter(':')} " + } else { + query.lastIndexOfAny(TagTerminators) to + if (keyword.endsWith(':')) keyword else "${wrapTagKeyword(keyword)} " + } + val keywords = if (index == -1) { + keyword + } else { + "${query.substring(0, index + 1).trimEnd()} $keyword" + } searchFieldState.setTextAndPlaceCursorAtEnd(keywords) } } @@ -152,13 +160,11 @@ fun SearchBarScreen( val query = searchFieldState.text.toString() suggestionProvider?.run { providerSuggestions(query)?.let { emit(it) } } mSearchDatabase.suggestions(query, 128).forEach { emit(KeywordSuggestion(it)) } - if (query.isNotEmpty() && !query.endsWith(' ')) { - EhTagDatabase.suggestion( - query.substringAfterLast(' '), - Settings.showTagTranslations, - ).forEach { (tag, hint) -> - emit(TagSuggestion(hint, tag)) - } + val index = if (localSearch) query.lastIndexOf(' ') else query.lastIndexOfAny(TagTerminators) + val keyword = query.substring(index + 1).trimStart() + if (keyword.isNotEmpty()) { + EhTagDatabase.suggestion(keyword, Settings.showTagTranslations).take(50) + .forEach { emit(TagSuggestion(it.hint, it.tag)) } } } } @@ -304,9 +310,7 @@ fun SearchBarScreen( } } -fun wrapTagKeyword(keyword: String, translate: Boolean = false): String = if (keyword.endsWith(':')) { - keyword -} else { +fun wrapTagKeyword(keyword: String, translate: Boolean = false): String = run { val tag = keyword.substringAfter(':') val prefix = keyword.dropLast(tag.length + 1) if (translate) { @@ -321,6 +325,7 @@ fun wrapTagKeyword(keyword: String, translate: Boolean = false): String = if (ke } } +private val TagTerminators = charArrayOf('"', '$') private val WhitespaceRegex = Regex("\\s+") private val SearchBarHorizontalPadding = 16.dp private val M3SearchBarMaxWidth = 720.dp diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/tools/Dialog.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/tools/Dialog.kt index 7a42edafe..5b33b9721 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/tools/Dialog.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/tools/Dialog.kt @@ -259,9 +259,9 @@ suspend fun awaitSelectTags(): List = dialog { cont -> }, ) val query = state.text.toString().trim().takeIf { s -> s.isNotEmpty() } - var items by remember { mutableStateOf(emptyList>()) } + var items by remember { mutableStateOf(emptyList()) } LaunchedEffect(suggestionTranslate, query) { - items = query?.let { suggestion(query, suggestionTranslate).take(15).toList() }.orEmpty() + items = query?.let { suggestion(query, suggestionTranslate).take(15) }.orEmpty() expanded = items.isNotEmpty() } ExposedDropdownMenu(