tag suggestion
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user