Implement Tagger suggestion (#1968)

* Implement Tagger suggestion

* Refine suggestFlow impl

* Refine suggest

* Revert

* Refine display

* Refine

* Refine

* Refine

* Refine

* Update app/src/main/kotlin/com/hippo/ehviewer/client/EhTagDatabase.kt

Co-authored-by: FooIbar <118464521+FooIbar@users.noreply.github.com>

* Refine

* Refine

* Refine

---------

Co-authored-by: Dude so hot <djohn@fbi.gov>
Co-authored-by: FooIbar <118464521+FooIbar@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2024-11-23 15:19:56 +00:00
1 parent 7047de4959
commit 8babadc33e
5 files changed
+132 -66

No files matched your search

@@ -22,6 +22,7 @@ import androidx.compose.runtime.setValue
import com.hippo.ehviewer.EhApplication.Companion.ktorClient
import com.hippo.ehviewer.R
import com.hippo.ehviewer.client.data.TagNamespace
import com.hippo.ehviewer.ui.screen.implicit
import com.hippo.ehviewer.util.AppConfig
import com.hippo.ehviewer.util.FileUtils
import com.hippo.ehviewer.util.copyTo
@@ -35,7 +36,7 @@ import java.io.File
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
@@ -63,60 +64,56 @@ object EhTagDatabase : CoroutineScope {
fun getTranslation(prefix: String? = NAMESPACE_PREFIX, tag: String?): String? = tagGroups[prefix]?.get(tag)?.trim()?.ifEmpty { null }
private fun internalSuggestFlow(
tags: Map<String, String>,
keyword: String,
translate: Boolean,
exactly: Boolean,
): Flow<Pair<String?, String>> = flow {
private fun internalSuggestFlow(tags: Map<String, String>, keyword: String, translate: Boolean, exactly: Boolean) = flow {
if (exactly) {
tags[keyword]?.let {
emit(Pair(it.takeIf { translate }, keyword))
emit(keyword to it.takeIf { translate })
}
} else {
if (translate) {
tags.forEach { (tag, hint) ->
if (tag != keyword &&
(tag.containsIgnoreSpace(keyword) || hint.containsIgnoreSpace(keyword))
) {
emit(Pair(hint, tag))
if (tag != keyword && (tag.containsIgnoreSpace(keyword) || hint.containsIgnoreSpace(keyword))) {
emit(tag to hint)
}
}
} else {
tags.keys.forEach { tag ->
if (tag != keyword && tag.containsIgnoreSpace(keyword)) {
emit(Pair(null, tag))
emit(tag to null)
}
}
}
}
}
// Construct a cold flow for tag database suggestions
fun suggestFlow(
keyword: String,
translate: Boolean,
exactly: Boolean = false,
): Flow<Pair<String?, String>> = flow {
var mKeyword = keyword
PREFIXES.forEach { mKeyword = mKeyword.removePrefix(it) }
val prefix = keyword.dropLast(mKeyword.length)
val namespace = mKeyword.substringBefore(':')
val tagKeyword = mKeyword.drop(namespace.length + 1)
val namespacePrefix = TagNamespace.from(namespace)?.prefix ?: namespace
val tags = tagGroups[namespacePrefix.takeIf { tagKeyword.isNotEmpty() && it != NAMESPACE_PREFIX }]
context(Context)
fun suggestions(keyword: String, translate: Boolean) = flow {
val doTranslate = translate && isTranslatable(implicit<Context>())
if (initialized) {
emitAll(suggestFlow(keyword, doTranslate, true))
emitAll(suggestFlow(keyword, doTranslate, false))
}
}
private fun suggestFlow(keyword: String, translate: Boolean, exactly: Boolean) = flow {
val kwd = PREFIXES.fold(keyword) { kwd, pfx -> kwd.removePrefix(pfx) }
val prefix = keyword.dropLast(kwd.length)
val ns = kwd.substringBefore(':')
val tag = kwd.drop(ns.length + 1)
val namespacePrefix = TagNamespace.from(ns)?.prefix ?: ns
val tags = tagGroups[namespacePrefix.takeIf { tag.isNotEmpty() && it != NAMESPACE_PREFIX }]
tags?.let {
internalSuggestFlow(it, tagKeyword, translate, exactly).collect { (hint, tag) ->
emit(Pair(hint, "$prefix$namespacePrefix:$tag"))
internalSuggestFlow(it, tag, translate, exactly).collect { (tag, hint) ->
emit("$prefix$namespacePrefix:$tag" to hint)
}
} ?: tagGroups.forEach { (namespacePrefix, tags) ->
if (namespacePrefix != NAMESPACE_PREFIX) {
internalSuggestFlow(tags, mKeyword, translate, exactly).collect { (hint, tag) ->
emit(Pair(hint, "$prefix$namespacePrefix:$tag"))
internalSuggestFlow(tags, kwd, translate, exactly).collect { (tag, hint) ->
emit("$prefix$namespacePrefix:$tag" to hint)
}
} else {
internalSuggestFlow(tags, mKeyword, translate, exactly).collect { (hint, namespacePrefix) ->
emit(Pair(hint, "$prefix$namespacePrefix:"))
internalSuggestFlow(tags, kwd, translate, exactly).collect { (ns, hint) ->
emit("$prefix$ns:" to hint)
}
}
}
@@ -143,19 +143,17 @@ fun SearchBarScreen(
}
}
fun mergedSuggestionFlow(): Flow<Suggestion> = flow {
val query = searchFieldState.text.toString()
suggestionProvider?.run { providerSuggestions(query)?.let { emit(it) } }
mSearchDatabase.suggestions(query, 128).forEach { emit(KeywordSuggestion(it)) }
EhTagDatabase.takeIf { it.initialized }?.run {
fun mergedSuggestionFlow(): Flow<Suggestion> = with(context) {
flow {
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(' ')) {
val keyword = query.substringAfterLast(' ')
val translate = Settings.showTagTranslations && isTranslatable(context)
suggestFlow(keyword, translate, true).collect {
emit(TagSuggestion(it.first, it.second))
}
suggestFlow(keyword, translate).collect {
emit(TagSuggestion(it.first, it.second))
EhTagDatabase.suggestions(
query.substringAfterLast(' '),
Settings.showTagTranslations,
).collect { (tag, hint) ->
emit(TagSuggestion(hint, tag))
}
}
}
@@ -1,5 +1,6 @@
package com.hippo.ehviewer.ui.tools
import android.content.Context
import androidx.annotation.StringRes
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@@ -32,10 +33,15 @@ import androidx.compose.material.icons.filled.Info
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.AlertDialogDefaults
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDefaults
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.DisplayMode
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExposedDropdownMenuAnchorType
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.InputChip
@@ -43,6 +49,7 @@ import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.ProvideTextStyle
import androidx.compose.material3.RadioButton
import androidx.compose.material3.SelectableDates
import androidx.compose.material3.ShapeDefaults
@@ -70,6 +77,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
@@ -83,12 +91,16 @@ import arrow.core.raise.Raise
import arrow.core.raise.either
import arrow.core.right
import com.hippo.ehviewer.R
import com.hippo.ehviewer.client.EhTagDatabase
import com.hippo.ehviewer.client.EhTagDatabase.suggestions
import com.jamal.composeprefs3.ui.ifNotNullThen
import com.jamal.composeprefs3.ui.ifTrueThen
import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
import kotlinx.coroutines.CancellableContinuation
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
@@ -160,9 +172,11 @@ value class DialogState(val field: MutableComposable = mutableStateOf(null)) : M
)
}
suspend fun awaitSelectTags() = dialog { cont ->
context(Context)
suspend fun awaitSelectTags(): List<String> = dialog { cont ->
val selected = remember { mutableStateListOf<String>() }
val state = rememberTextFieldState()
var suggestionTranslate by rememberMutableStateInDataStore("SuggestionTranslate") { false }
AlertDialog(
onDismissRequest = { cont.cancel() },
confirmButton = {
@@ -177,7 +191,23 @@ value class DialogState(val field: MutableComposable = mutableStateOf(null)) : M
content = { Text(text = stringResource(id = android.R.string.cancel)) },
)
},
title = { Text(text = stringResource(id = R.string.action_add_tag)) },
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(text = stringResource(id = R.string.action_add_tag))
val context = LocalContext.current
if (EhTagDatabase.isTranslatable(context)) {
Spacer(modifier = Modifier.weight(1f))
Text(
text = stringResource(id = R.string.translate_tag_for_tagger),
style = MaterialTheme.typography.titleSmall,
)
Checkbox(
checked = suggestionTranslate,
onCheckedChange = { suggestionTranslate = !suggestionTranslate },
)
}
}
},
text = {
Column {
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
@@ -196,27 +226,66 @@ value class DialogState(val field: MutableComposable = mutableStateOf(null)) : M
)
}
}
OutlinedTextField(
state = state,
label = { Text(text = stringResource(id = R.string.action_add_tag_tip)) },
trailingIcon = {
IconButton(
onClick = {
val text = state.text.toString().trim()
if (text.isNotEmpty()) {
selected += text
state.clearText()
ExposedDropdownMenuBox(
expanded = true,
onExpandedChange = { },
) {
OutlinedTextField(
modifier = Modifier.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryEditable),
state = state,
label = { Text(text = stringResource(id = R.string.action_add_tag_tip)) },
trailingIcon = {
IconButton(
onClick = {
val text = state.text.toString().trim()
if (text.isNotEmpty()) {
selected += text
state.clearText()
}
},
content = {
Icon(
imageVector = Icons.Default.Add,
contentDescription = null,
)
},
)
},
)
val query = state.text.toString().trim().takeIf { s -> s.isNotEmpty() }
Await(suggestionTranslate, { query?.let { suggestions(query, suggestionTranslate).take(4).toList() } }) { items ->
if (!items.isNullOrEmpty()) {
ExposedDropdownMenu(
expanded = true,
onDismissRequest = { },
) {
items.forEach { (tag, hint) ->
DropdownMenuItem(
text = {
Column {
Text(text = tag, maxLines = 1)
ProvideTextStyle(MaterialTheme.typography.bodySmall) {
if (hint != null) {
Text(
text = hint,
maxLines = 1,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
},
onClick = {
selected += tag
state.clearText()
},
contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding,
)
}
},
content = {
Icon(
imageVector = Icons.Default.Add,
contentDescription = null,
)
},
)
},
)
}
}
}
}
}
},
)
@@ -85,6 +85,7 @@
<string name="quick_search_tip">点击“+”来添加快速搜索</string>
<string name="add_quick_search_dialog_title">添加快速搜索</string>
<string name="name_is_empty">名称为空</string>
<string name="translate_tag_for_tagger">使用翻译标签</string>
<string name="delete">删除</string>
<string name="add_quick_search_tip">画廊列表的状态将被保存为快速搜索。如果你想保存搜索面板的状态,请先执行搜索。</string>
<string name="readme">读我</string>
+1
View File
@@ -100,6 +100,7 @@
<string name="quick_search">Quick search</string>
<string name="quick_search_tip">Tap \"+\" to add Quick Search</string>
<string name="add_quick_search_dialog_title">Add Quick Search</string>
<string name="translate_tag_for_tagger">Use tag translation</string>
<string name="name_is_empty">Name is empty</string>
<string name="delete">Delete</string>
<string name="add_quick_search_tip">The state of gallery list will be saved as quick search. Perform a search first to save the state of search panel.</string>