tag suggestion

This commit is contained in:
tom5079
2025-03-09 12:09:33 -07:00
parent a9cd3db27e
commit 5f79c11303
3 changed files with 127 additions and 71 deletions

View File

@@ -18,15 +18,15 @@ import kotlinx.coroutines.withContext
import kotlinx.datetime.Clock.System.now import kotlinx.datetime.Clock.System.now
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonPrimitive
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.nio.IntBuffer import java.nio.IntBuffer
import kotlin.time.Duration import kotlin.time.Duration
import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.minutes
const val domain = "ltn.hitomi.la" const val domain = "ltn.hitomi.la"
const val galleryBlockExtension = ".html"
const val galleryBlockDir = "galleryblock"
const val nozomiExtension = ".nozomi" const val nozomiExtension = ".nozomi"
const val compressedNozomiPrefix = "n" const val compressedNozomiPrefix = "n"
@@ -35,8 +35,10 @@ const val B = 16
const val indexDir = "tagindex" const val indexDir = "tagindex"
const val maxNodeSize = 464 const val maxNodeSize = 464
const val galleriesIndexDir = "galleriesindex" const val galleriesIndexDir = "galleriesindex"
const val languagesIndexDir = "languagesindex" const val tagIndexDomain = "tagindex.hitomi.la"
const val nozomiURLIndexDir = "nozomiurlindex"
const val separator = "-"
const val extension = ".html"
data class Suggestion( data class Suggestion(
val tag: SearchQuery.Tag, val tag: SearchQuery.Tag,
@@ -183,37 +185,16 @@ object HitomiHttpClient {
return getURLAtRange(url, offset until (offset + length)).asIntBuffer() return getURLAtRange(url, offset until (offset + length)).asIntBuffer()
} }
private suspend fun getSuggestionsFromData(field: String, data: Node.Data): List<Suggestion> { private fun encodeSearchQueryForUrl(s: Char) =
val url = "https://$domain/$indexDir/$field.${tagIndexVersion.getValue()}.data" when (s) {
val (offset, length) = data ' ' -> "_"
'/' -> "slash"
check(data.length in 1..10000) { "Invalid length ${data.length}" } '.' -> "dot"
else -> s.toString()
val buffer = getURLAtRange(url, offset..<offset + length).order(ByteOrder.BIG_ENDIAN)
val numberOfSuggestions = buffer.int
check(numberOfSuggestions in 1..100) { "Number of suggestions $numberOfSuggestions is too long" }
return buildList {
for (i in 0..<numberOfSuggestions) {
val namespaceLen = buffer.int
val namespace = ByteArray(namespaceLen).apply {
buffer.get(this)
}.toString(charset("UTF-8"))
val tagLen = buffer.int
val tag = ByteArray(tagLen).apply {
buffer.get(this)
}.toString(charset("UTF-8"))
val count = buffer.int
add(Suggestion(SearchQuery.Tag(namespace, tag), count))
}
}
} }
private fun sanitize(s: String) = s.replace(Regex("[/#]"), "")
private suspend fun getGalleryIDsFromNozomi( private suspend fun getGalleryIDsFromNozomi(
area: String?, area: String?,
tag: String, tag: String,
@@ -258,11 +239,44 @@ object HitomiHttpClient {
suspend fun getSuggestionsForQuery(query: SearchQuery.Tag): Result<List<Suggestion>> = suspend fun getSuggestionsForQuery(query: SearchQuery.Tag): Result<List<Suggestion>> =
runCatching { runCatching {
val field = query.namespace ?: "global" val field = query.namespace ?: "global"
val key = Node.Key(query.tag) val chars = query.tag.map(::encodeSearchQueryForUrl)
val node = getNodeAtAddress(field, 0)
val data = bSearch(field, key, node)
data?.let { getSuggestionsFromData(field, data) } ?: emptyList() val suggestions = json.parseToJsonElement(
withContext(Dispatchers.IO) {
httpClient.get(
"https://$tagIndexDomain/$field${
if (chars.isNotEmpty()) "/${
chars.joinToString(
"/"
)
}" else ""
}.json"
).bodyAsText()
}
)
buildList {
suggestions.jsonArray.forEach { suggestionRaw ->
val suggestion = suggestionRaw.jsonArray
if (suggestion.size < 3) {
return@forEach
}
val namespace = suggestion[2].jsonPrimitive.contentOrNull ?: ""
val tag =
sanitize(suggestion[0].jsonPrimitive.contentOrNull ?: return@forEach)
add(
Suggestion(
SearchQuery.Tag(
namespace,
tag
),
suggestion[1].jsonPrimitive.contentOrNull?.toIntOrNull() ?: 0,
)
)
}
}
} }
suspend fun getGalleryInfo(galleryID: Int) = runCatching { suspend fun getGalleryInfo(galleryID: Int) = runCatching {

View File

@@ -28,6 +28,7 @@ import androidx.compose.material.icons.filled.AddCircleOutline
import androidx.compose.material.icons.filled.RemoveCircleOutline import androidx.compose.material.icons.filled.RemoveCircleOutline
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardColors import androidx.compose.material3.CardColors
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@@ -195,7 +196,14 @@ fun TagSuggestionList(
val suggestionListSnapshot = suggestionList val suggestionListSnapshot = suggestionList
if (suggestionListSnapshot == null) { if (suggestionListSnapshot == null) {
Row(
Modifier.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
CircularProgressIndicator(Modifier.size(24.dp))
Text("Loading") Text("Loading")
}
} else if (suggestionListSnapshot.isNotEmpty()) { } else if (suggestionListSnapshot.isNotEmpty()) {
Column( Column(
modifier = Modifier.padding(8.dp), modifier = Modifier.padding(8.dp),
@@ -272,7 +280,8 @@ fun EditableTagChip(
positionY = it.positionInRoot().y positionY = it.positionInRoot().y
}, },
shape = RoundedCornerShape(16.dp), shape = RoundedCornerShape(16.dp),
color = surfaceColor color = surfaceColor,
shadowElevation = 4.dp
) { ) {
AnimatedContent(targetState = expanded, label = "open tag editor") { targetExpanded -> AnimatedContent(targetState = expanded, label = "open tag editor") { targetExpanded ->
if (!targetExpanded) { if (!targetExpanded) {
@@ -424,7 +433,7 @@ fun NewQueryChip(
} }
} }
Surface(shape = RoundedCornerShape(16.dp)) { Surface(shape = RoundedCornerShape(16.dp), shadowElevation = 4.dp) {
AnimatedContent(targetState = opened, label = "add new query") { targetOpened -> AnimatedContent(targetState = opened, label = "add new query") { targetOpened ->
if (targetOpened) { if (targetOpened) {
Column { Column {

View File

@@ -236,26 +236,32 @@ fun TagChip(
shape = shape, shape = shape,
color = surfaceColor, color = surfaceColor,
onClick = { onClick(tag) }, onClick = { onClick(tag) },
content = inner content = inner,
shadowElevation = 4.dp
) )
else else
Surface( Surface(
modifier, modifier,
shape = shape, shape = shape,
color = surfaceColor, color = surfaceColor,
content = inner content = inner,
shadowElevation = 4.dp
) )
} }
@Composable @Composable
fun QueryView( fun QueryView(
query: SearchQuery?, query: SearchQuery?,
topLevel: Boolean = true topLevel: Boolean = true,
) { ) {
val modifier = if (topLevel) { val modifier = if (topLevel) {
Modifier Modifier
} else { } else {
Modifier.border(width = 0.5.dp, color = LocalContentColor.current, shape = CardDefaults.shape) Modifier.border(
width = 0.5.dp,
color = LocalContentColor.current,
shape = CardDefaults.shape
)
} }
when (query) { when (query) {
@@ -269,12 +275,14 @@ fun QueryView(
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
) )
} }
is SearchQuery.Tag -> { is SearchQuery.Tag -> {
TagChip( TagChip(
query, query,
enabled = false enabled = false
) )
} }
is SearchQuery.Or -> { is SearchQuery.Or -> {
Row( Row(
modifier = modifier.padding(vertical = 2.dp, horizontal = 4.dp), modifier = modifier.padding(vertical = 2.dp, horizontal = 4.dp),
@@ -282,11 +290,14 @@ fun QueryView(
horizontalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
query.queries.forEachIndexed { index, subQuery -> query.queries.forEachIndexed { index, subQuery ->
if (index != 0) { Text("+") } if (index != 0) {
Text("+")
}
QueryView(subQuery, topLevel = false) QueryView(subQuery, topLevel = false)
} }
} }
} }
is SearchQuery.And -> { is SearchQuery.And -> {
Row( Row(
horizontalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp),
@@ -297,6 +308,7 @@ fun QueryView(
} }
} }
} }
is SearchQuery.Not -> { is SearchQuery.Not -> {
Row( Row(
modifier = modifier.padding(vertical = 2.dp, horizontal = 4.dp), modifier = modifier.padding(vertical = 2.dp, horizontal = 4.dp),
@@ -318,10 +330,13 @@ fun SearchBar(
onSearchBarPositioned: (Int) -> Unit, onSearchBarPositioned: (Int) -> Unit,
topOffset: Int, topOffset: Int,
onTopOffsetChange: (Int) -> Unit, onTopOffsetChange: (Int) -> Unit,
content: @Composable () -> Unit content: @Composable () -> Unit,
) { ) {
var focused by remember { mutableStateOf(false) } var focused by remember { mutableStateOf(false) }
val scrimAlpha: Float by animateFloatAsState(if (focused && contentType == ContentType.SINGLE_PANE) 0.3f else 0f, label = "scrim alpha") val scrimAlpha: Float by animateFloatAsState(
if (focused && contentType == ContentType.SINGLE_PANE) 0.3f else 0f,
label = "scrim alpha"
)
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
@@ -351,7 +366,10 @@ fun SearchBar(
focused = false focused = false
} }
) { ) {
val height: Dp by animateDpAsState(if (focused) maxHeight else 60.dp, label = "searchbar height") val height: Dp by animateDpAsState(
if (focused) maxHeight else 60.dp,
label = "searchbar height"
)
val cardShape = RoundedCornerShape(30.dp) val cardShape = RoundedCornerShape(30.dp)
content() content()
@@ -359,7 +377,8 @@ fun SearchBar(
Box( Box(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.background(Color.Black.copy(alpha = scrimAlpha))) .background(Color.Black.copy(alpha = scrimAlpha))
)
Card( Card(
modifier = Modifier modifier = Modifier
@@ -381,7 +400,11 @@ fun SearchBar(
elevation = CardDefaults.cardElevation(6.dp) elevation = CardDefaults.cardElevation(6.dp)
) { ) {
Box { Box {
androidx.compose.animation.AnimatedVisibility(!focused, enter = fadeIn(), exit = fadeOut()) { androidx.compose.animation.AnimatedVisibility(
!focused,
enter = fadeIn(),
exit = fadeOut()
) {
Row( Row(
modifier = Modifier modifier = Modifier
.heightIn(min = 60.dp) .heightIn(min = 60.dp)
@@ -393,11 +416,16 @@ fun SearchBar(
Box(Modifier.size(8.dp)) Box(Modifier.size(8.dp))
} }
} }
androidx.compose.animation.AnimatedVisibility(focused, enter = fadeIn(), exit = fadeOut()) { androidx.compose.animation.AnimatedVisibility(
focused,
enter = fadeIn(),
exit = fadeOut()
) {
Column( Column(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.padding(top = 8.dp, start = 8.dp, end = 8.dp)) { .padding(top = 8.dp, start = 8.dp, end = 8.dp)
) {
IconButton( IconButton(
onClick = { onClick = {
focused = false focused = false
@@ -465,7 +493,11 @@ fun GalleryList(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
Text("(´∇`)", style = MaterialTheme.typography.headlineMedium, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f)) Text(
"(´∇`)",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f)
)
Text("No sources found!\nLet's go download one.", textAlign = TextAlign.Center) Text("No sources found!\nLet's go download one.", textAlign = TextAlign.Center)
} }
} }
@@ -478,7 +510,8 @@ fun GalleryList(
) { ) {
LazyColumn( LazyColumn(
modifier = listModifier, modifier = listModifier,
contentPadding = WindowInsets.systemBars.asPaddingValues().let { systemBarPaddingValues -> contentPadding = WindowInsets.systemBars.asPaddingValues()
.let { systemBarPaddingValues ->
val layoutDirection = LocalLayoutDirection.current val layoutDirection = LocalLayoutDirection.current
PaddingValues( PaddingValues(
top = systemBarPaddingValues.calculateTopPadding() + 96.dp, top = systemBarPaddingValues.calculateTopPadding() + 96.dp,
@@ -509,7 +542,7 @@ fun GalleryList(
fun DetailScreen( fun DetailScreen(
galleryInfo: GalleryInfo, galleryInfo: GalleryInfo,
closeGalleryDetails: () -> Unit = { }, closeGalleryDetails: () -> Unit = { },
openGallery: (GalleryInfo) -> Unit = { } openGallery: (GalleryInfo) -> Unit = { },
) { ) {
var thumbnailUrl by remember { mutableStateOf<String?>(null) } var thumbnailUrl by remember { mutableStateOf<String?>(null) }
@@ -577,7 +610,7 @@ fun SearchScreen(
closeGalleryDetails: () -> Unit, closeGalleryDetails: () -> Unit,
onQueryChange: (SearchQuery?) -> Unit, onQueryChange: (SearchQuery?) -> Unit,
loadSearchResult: (IntRange) -> Unit, loadSearchResult: (IntRange) -> Unit,
openGallery: (GalleryInfo) -> Unit openGallery: (GalleryInfo) -> Unit,
) { ) {
val itemsPerPage by remember { mutableIntStateOf(20) } val itemsPerPage by remember { mutableIntStateOf(20) }