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:
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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user