From 5f79c11303dbb0cfaed275046f09708d57578f97 Mon Sep 17 00:00:00 2001 From: tom5079 <7948651+tom5079@users.noreply.github.com> Date: Sun, 9 Mar 2025 12:09:33 -0700 Subject: [PATCH] tag suggestion --- .../pupil/networking/HitomiHttpClient.kt | 90 ++++++++++-------- .../quaver/pupil/ui/composable/QueryEditor.kt | 15 ++- .../pupil/ui/composable/SearchScreen.kt | 93 +++++++++++++------ 3 files changed, 127 insertions(+), 71 deletions(-) diff --git a/app/src/main/java/xyz/quaver/pupil/networking/HitomiHttpClient.kt b/app/src/main/java/xyz/quaver/pupil/networking/HitomiHttpClient.kt index 335247b3..73b72064 100644 --- a/app/src/main/java/xyz/quaver/pupil/networking/HitomiHttpClient.kt +++ b/app/src/main/java/xyz/quaver/pupil/networking/HitomiHttpClient.kt @@ -18,15 +18,15 @@ import kotlinx.coroutines.withContext import kotlinx.datetime.Clock.System.now import kotlinx.datetime.Instant 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.ByteOrder import java.nio.IntBuffer import kotlin.time.Duration import kotlin.time.Duration.Companion.minutes const val domain = "ltn.hitomi.la" -const val galleryBlockExtension = ".html" -const val galleryBlockDir = "galleryblock" const val nozomiExtension = ".nozomi" const val compressedNozomiPrefix = "n" @@ -35,8 +35,10 @@ const val B = 16 const val indexDir = "tagindex" const val maxNodeSize = 464 const val galleriesIndexDir = "galleriesindex" -const val languagesIndexDir = "languagesindex" -const val nozomiURLIndexDir = "nozomiurlindex" +const val tagIndexDomain = "tagindex.hitomi.la" + +const val separator = "-" +const val extension = ".html" data class Suggestion( val tag: SearchQuery.Tag, @@ -183,36 +185,15 @@ object HitomiHttpClient { return getURLAtRange(url, offset until (offset + length)).asIntBuffer() } - private suspend fun getSuggestionsFromData(field: String, data: Node.Data): List { - val url = "https://$domain/$indexDir/$field.${tagIndexVersion.getValue()}.data" - val (offset, length) = data - - check(data.length in 1..10000) { "Invalid length ${data.length}" } - - val buffer = getURLAtRange(url, offset.. "_" + '/' -> "slash" + '.' -> "dot" + else -> s.toString() } - } + + private fun sanitize(s: String) = s.replace(Regex("[/#]"), "") private suspend fun getGalleryIDsFromNozomi( area: String?, @@ -258,11 +239,44 @@ object HitomiHttpClient { suspend fun getSuggestionsForQuery(query: SearchQuery.Tag): Result> = runCatching { val field = query.namespace ?: "global" - val key = Node.Key(query.tag) - val node = getNodeAtAddress(field, 0) - val data = bSearch(field, key, node) + val chars = query.tag.map(::encodeSearchQueryForUrl) - 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 { diff --git a/app/src/main/java/xyz/quaver/pupil/ui/composable/QueryEditor.kt b/app/src/main/java/xyz/quaver/pupil/ui/composable/QueryEditor.kt index 3e6ea8c1..6b89861b 100644 --- a/app/src/main/java/xyz/quaver/pupil/ui/composable/QueryEditor.kt +++ b/app/src/main/java/xyz/quaver/pupil/ui/composable/QueryEditor.kt @@ -28,6 +28,7 @@ import androidx.compose.material.icons.filled.AddCircleOutline import androidx.compose.material.icons.filled.RemoveCircleOutline import androidx.compose.material3.Card import androidx.compose.material3.CardColors +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -195,7 +196,14 @@ fun TagSuggestionList( val suggestionListSnapshot = suggestionList if (suggestionListSnapshot == null) { - Text("Loading") + Row( + Modifier.padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + CircularProgressIndicator(Modifier.size(24.dp)) + Text("Loading") + } } else if (suggestionListSnapshot.isNotEmpty()) { Column( modifier = Modifier.padding(8.dp), @@ -272,7 +280,8 @@ fun EditableTagChip( positionY = it.positionInRoot().y }, shape = RoundedCornerShape(16.dp), - color = surfaceColor + color = surfaceColor, + shadowElevation = 4.dp ) { AnimatedContent(targetState = expanded, label = "open tag editor") { 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 -> if (targetOpened) { Column { diff --git a/app/src/main/java/xyz/quaver/pupil/ui/composable/SearchScreen.kt b/app/src/main/java/xyz/quaver/pupil/ui/composable/SearchScreen.kt index d3de388f..fa738262 100644 --- a/app/src/main/java/xyz/quaver/pupil/ui/composable/SearchScreen.kt +++ b/app/src/main/java/xyz/quaver/pupil/ui/composable/SearchScreen.kt @@ -236,26 +236,32 @@ fun TagChip( shape = shape, color = surfaceColor, onClick = { onClick(tag) }, - content = inner + content = inner, + shadowElevation = 4.dp ) else Surface( modifier, shape = shape, color = surfaceColor, - content = inner + content = inner, + shadowElevation = 4.dp ) } @Composable fun QueryView( query: SearchQuery?, - topLevel: Boolean = true + topLevel: Boolean = true, ) { val modifier = if (topLevel) { Modifier } 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) { @@ -269,24 +275,29 @@ fun QueryView( color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) ) } + is SearchQuery.Tag -> { TagChip( query, enabled = false ) } + is SearchQuery.Or -> { Row( - modifier = modifier.padding(vertical=2.dp, horizontal=4.dp), + modifier = modifier.padding(vertical = 2.dp, horizontal = 4.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), ) { query.queries.forEachIndexed { index, subQuery -> - if (index != 0) { Text("+") } + if (index != 0) { + Text("+") + } QueryView(subQuery, topLevel = false) } } } + is SearchQuery.And -> { Row( horizontalArrangement = Arrangement.spacedBy(4.dp), @@ -297,9 +308,10 @@ fun QueryView( } } } + is SearchQuery.Not -> { Row( - modifier = modifier.padding(vertical=2.dp, horizontal=4.dp), + modifier = modifier.padding(vertical = 2.dp, horizontal = 4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically ) { @@ -318,10 +330,13 @@ fun SearchBar( onSearchBarPositioned: (Int) -> Unit, topOffset: Int, onTopOffsetChange: (Int) -> Unit, - content: @Composable () -> Unit + content: @Composable () -> Unit, ) { 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() } @@ -351,7 +366,10 @@ fun SearchBar( 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) content() @@ -359,7 +377,8 @@ fun SearchBar( Box( Modifier .fillMaxSize() - .background(Color.Black.copy(alpha = scrimAlpha))) + .background(Color.Black.copy(alpha = scrimAlpha)) + ) Card( modifier = Modifier @@ -381,7 +400,11 @@ fun SearchBar( elevation = CardDefaults.cardElevation(6.dp) ) { Box { - androidx.compose.animation.AnimatedVisibility(!focused, enter = fadeIn(), exit = fadeOut()) { + androidx.compose.animation.AnimatedVisibility( + !focused, + enter = fadeIn(), + exit = fadeOut() + ) { Row( modifier = Modifier .heightIn(min = 60.dp) @@ -393,11 +416,16 @@ fun SearchBar( Box(Modifier.size(8.dp)) } } - androidx.compose.animation.AnimatedVisibility(focused, enter = fadeIn(), exit = fadeOut()) { + androidx.compose.animation.AnimatedVisibility( + focused, + enter = fadeIn(), + exit = fadeOut() + ) { Column( Modifier .fillMaxSize() - .padding(top = 8.dp, start = 8.dp, end = 8.dp)) { + .padding(top = 8.dp, start = 8.dp, end = 8.dp) + ) { IconButton( onClick = { focused = false @@ -433,7 +461,7 @@ fun GalleryList( var topOffset by remember { mutableIntStateOf(0) } var searchBarPosition by remember { mutableIntStateOf(0) } - val listModifier = Modifier.nestedScroll(object: NestedScrollConnection { + val listModifier = Modifier.nestedScroll(object : NestedScrollConnection { override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { topOffset = (topOffset + available.y.roundToInt()).coerceIn(-searchBarPosition, 0) return Offset.Zero @@ -453,7 +481,7 @@ fun GalleryList( topOffset = topOffset, onTopOffsetChange = { topOffset = it }, ) { - AnimatedVisibility (loading, enter = fadeIn(), exit = fadeOut()) { + AnimatedVisibility(loading, enter = fadeIn(), exit = fadeOut()) { Box(Modifier.fillMaxSize()) { CircularProgressIndicator(Modifier.align(Alignment.Center)) } @@ -465,7 +493,11 @@ fun GalleryList( horizontalAlignment = Alignment.CenterHorizontally, 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) } } @@ -474,23 +506,24 @@ fun GalleryList( OverscrollPager( prevPage = if (currentPage != 0) currentPage else null, nextPage = if (currentPage < maxPage) currentPage + 2 else null, - onPageTurn = { onPageChange(it-1) } + onPageTurn = { onPageChange(it - 1) } ) { LazyColumn( modifier = listModifier, - contentPadding = WindowInsets.systemBars.asPaddingValues().let { systemBarPaddingValues -> - val layoutDirection = LocalLayoutDirection.current - PaddingValues( - top = systemBarPaddingValues.calculateTopPadding() + 96.dp, - bottom = systemBarPaddingValues.calculateBottomPadding(), - start = systemBarPaddingValues.calculateStartPadding(layoutDirection), - end = systemBarPaddingValues.calculateEndPadding(layoutDirection), - ) - }, + contentPadding = WindowInsets.systemBars.asPaddingValues() + .let { systemBarPaddingValues -> + val layoutDirection = LocalLayoutDirection.current + PaddingValues( + top = systemBarPaddingValues.calculateTopPadding() + 96.dp, + bottom = systemBarPaddingValues.calculateBottomPadding(), + start = systemBarPaddingValues.calculateStartPadding(layoutDirection), + end = systemBarPaddingValues.calculateEndPadding(layoutDirection), + ) + }, verticalArrangement = Arrangement.spacedBy(8.dp), state = listState ) { - items(galleries, key = { it.id }) {galleryInfo -> + items(galleries, key = { it.id }) { galleryInfo -> DetailedGalleryInfo( modifier = Modifier .fillMaxWidth() @@ -509,7 +542,7 @@ fun GalleryList( fun DetailScreen( galleryInfo: GalleryInfo, closeGalleryDetails: () -> Unit = { }, - openGallery: (GalleryInfo) -> Unit = { } + openGallery: (GalleryInfo) -> Unit = { }, ) { var thumbnailUrl by remember { mutableStateOf(null) } @@ -577,7 +610,7 @@ fun SearchScreen( closeGalleryDetails: () -> Unit, onQueryChange: (SearchQuery?) -> Unit, loadSearchResult: (IntRange) -> Unit, - openGallery: (GalleryInfo) -> Unit + openGallery: (GalleryInfo) -> Unit, ) { val itemsPerPage by remember { mutableIntStateOf(20) }