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.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,37 +185,16 @@ object HitomiHttpClient {
return getURLAtRange(url, offset until (offset + length)).asIntBuffer()
}
private suspend fun getSuggestionsFromData(field: String, data: Node.Data): List<Suggestion> {
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..<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 encodeSearchQueryForUrl(s: Char) =
when (s) {
' ' -> "_"
'/' -> "slash"
'.' -> "dot"
else -> s.toString()
}
private fun sanitize(s: String) = s.replace(Regex("[/#]"), "")
private suspend fun getGalleryIDsFromNozomi(
area: String?,
tag: String,
@@ -258,11 +239,44 @@ object HitomiHttpClient {
suspend fun getSuggestionsForQuery(query: SearchQuery.Tag): Result<List<Suggestion>> =
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 {

View File

@@ -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) {
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 {

View File

@@ -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,11 +506,12 @@ 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 ->
contentPadding = WindowInsets.systemBars.asPaddingValues()
.let { systemBarPaddingValues ->
val layoutDirection = LocalLayoutDirection.current
PaddingValues(
top = systemBarPaddingValues.calculateTopPadding() + 96.dp,
@@ -490,7 +523,7 @@ fun GalleryList(
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<String?>(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) }