refactor(sessions): extract search formatting and paging logic
Extract SessionSearchFormatting and SessionsPaging helpers. Deduplicate paging calculation in SessionsViewModel and add unit tests.
This commit is contained in:
1 parent
f0c2d2f87b
commit
6876db0393
7 files changed
+315
-112
No files matched your search
@@ -0,0 +1,98 @@
|
||||
package com.m57.hermescontrol.ui.sessions
|
||||
|
||||
import com.m57.hermescontrol.data.model.SessionInfo
|
||||
import com.m57.hermescontrol.data.model.SessionSearchResult
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
* Converts a backend search hit into a display model WITHOUT faking a title.
|
||||
* The backend returns no session name, only a matched `snippet` + metadata, so the
|
||||
* card must render the snippet as a match excerpt (never as the title). `title` is
|
||||
* deliberately left null so [SearchResultCard] shows the honest "Match" layout.
|
||||
*/
|
||||
internal fun SessionSearchResult.toSessionInfo(): SessionInfo =
|
||||
SessionInfo(
|
||||
id = session_id,
|
||||
title = null,
|
||||
preview = snippet,
|
||||
source = source,
|
||||
model = model,
|
||||
started_at = session_started,
|
||||
// message_count/status aren't in the search payload; leave null so the
|
||||
// search card hides those normal-list affordances.
|
||||
)
|
||||
|
||||
/**
|
||||
* Formats a backend epoch-seconds timestamp into a friendly, local relative/absolute
|
||||
* string. Used for search results where the session name is unknown.
|
||||
*/
|
||||
internal fun formatPlayedAt(epochSeconds: Double?): String? {
|
||||
if (epochSeconds == null || epochSeconds <= 0.0) return null
|
||||
val instant =
|
||||
try {
|
||||
Instant.ofEpochSecond(epochSeconds.toLong())
|
||||
} catch (_: Exception) {
|
||||
return null
|
||||
}
|
||||
val now = Instant.now()
|
||||
val diffSec = abs(now.epochSecond - instant.epochSecond)
|
||||
val absFormatter =
|
||||
DateTimeFormatter
|
||||
.ofLocalizedDateTime(FormatStyle.MEDIUM)
|
||||
.withZone(ZoneId.systemDefault())
|
||||
return when {
|
||||
diffSec < 60 -> "just now"
|
||||
diffSec < 3600 -> "${diffSec / 60}m ago"
|
||||
diffSec < 86400 -> "${diffSec / 3600}h ago"
|
||||
diffSec < 7 * 86400 -> "${diffSec / 86400}d ago"
|
||||
else -> absFormatter.format(instant)
|
||||
}
|
||||
}
|
||||
|
||||
private val searchSnippetJson = Json { isLenient = true }
|
||||
|
||||
/**
|
||||
* Makes a backend FTS snippet safe to present as prose. Some indexed messages are stored as
|
||||
* JSON, so prefer their human-readable text fields rather than rendering the entire payload.
|
||||
*/
|
||||
fun cleanSearchSnippet(snippet: String): String {
|
||||
val withoutHighlightMarkers = snippet.replace(">>>", "").replace("<<<", "").trim()
|
||||
val parsedSnippet =
|
||||
runCatching { searchSnippetJson.parseToJsonElement(withoutHighlightMarkers) }.getOrNull()
|
||||
val displayText = parsedSnippet?.searchText() ?: withoutHighlightMarkers
|
||||
return displayText
|
||||
.replace(Regex("\\s+"), " ")
|
||||
.trim()
|
||||
}
|
||||
|
||||
private fun JsonElement.searchText(): String? =
|
||||
when (this) {
|
||||
is JsonPrimitive -> {
|
||||
contentOrNull?.takeIf(String::isNotBlank)
|
||||
}
|
||||
|
||||
is JsonArray -> {
|
||||
mapNotNull { it.searchText() }
|
||||
.joinToString(" ")
|
||||
.takeIf(String::isNotBlank)
|
||||
}
|
||||
|
||||
is JsonObject -> {
|
||||
val textFieldNames =
|
||||
listOf("content", "text", "message", "body", "prompt", "summary", "title")
|
||||
val wrapperFieldNames = listOf("data", "result", "payload", "parts")
|
||||
textFieldNames
|
||||
.firstNotNullOfOrNull { fieldName -> this[fieldName]?.searchText() }
|
||||
?: wrapperFieldNames.firstNotNullOfOrNull { fieldName -> this[fieldName]?.searchText() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.m57.hermescontrol.ui.sessions
|
||||
|
||||
data class SessionPaginationResolution(
|
||||
val hasMore: Boolean,
|
||||
val total: Int,
|
||||
)
|
||||
|
||||
object SessionsPaging {
|
||||
/**
|
||||
* Computes hasMore and effective total when refreshing or loading the initial page of sessions.
|
||||
*/
|
||||
fun resolveInitialPaging(
|
||||
receivedCount: Int,
|
||||
pageSize: Int,
|
||||
backendTotal: Int,
|
||||
accumulatedCount: Int,
|
||||
): SessionPaginationResolution {
|
||||
val hasMore = receivedCount >= pageSize && backendTotal > accumulatedCount
|
||||
return SessionPaginationResolution(
|
||||
hasMore = hasMore,
|
||||
total = if (hasMore) backendTotal else accumulatedCount,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes hasMore and effective total when appending a page of sessions.
|
||||
*/
|
||||
fun resolveLoadMorePaging(
|
||||
receivedCount: Int,
|
||||
pageSize: Int,
|
||||
addedNewItems: Boolean,
|
||||
backendTotal: Int,
|
||||
accumulatedCount: Int,
|
||||
): SessionPaginationResolution {
|
||||
val receivedFullPage = receivedCount >= pageSize
|
||||
val hasMore = receivedFullPage && addedNewItems && backendTotal > accumulatedCount
|
||||
return SessionPaginationResolution(
|
||||
hasMore = hasMore,
|
||||
total = if (hasMore) backendTotal else accumulatedCount,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -116,17 +116,6 @@ import com.m57.hermescontrol.ui.common.StatusBadge
|
||||
import com.m57.hermescontrol.ui.common.StatusBadgeType
|
||||
import com.m57.hermescontrol.ui.common.ToastEffect
|
||||
import com.m57.hermescontrol.ui.common.listItemSpacing
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
* Auto-load the next history page when the user scrolls to within this many
|
||||
@@ -197,89 +186,6 @@ private fun highlightText(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a backend search hit into a display model WITHOUT faking a title.
|
||||
* The backend returns no session name, only a matched `snippet` + metadata, so the
|
||||
* card must render the snippet as a match excerpt (never as the title). `title` is
|
||||
* deliberately left null so [SearchResultCard] shows the honest "Match" layout.
|
||||
*/
|
||||
private fun SessionSearchResult.toSessionInfo(): SessionInfo =
|
||||
SessionInfo(
|
||||
id = session_id,
|
||||
title = null,
|
||||
preview = snippet,
|
||||
source = source,
|
||||
model = model,
|
||||
started_at = session_started,
|
||||
// message_count/status aren't in the search payload; leave null so the
|
||||
// search card hides those normal-list affordances.
|
||||
)
|
||||
|
||||
/**
|
||||
* Formats a backend epoch-seconds timestamp into a friendly, local relative/absolute
|
||||
* string. Used for search results where the session name is unknown.
|
||||
*/
|
||||
private fun formatPlayedAt(epochSeconds: Double?): String? {
|
||||
if (epochSeconds == null || epochSeconds <= 0.0) return null
|
||||
val instant =
|
||||
try {
|
||||
Instant.ofEpochSecond(epochSeconds.toLong())
|
||||
} catch (_: Exception) {
|
||||
return null
|
||||
}
|
||||
val now = Instant.now()
|
||||
val diffSec = abs(now.epochSecond - instant.epochSecond)
|
||||
val absFormatter =
|
||||
DateTimeFormatter
|
||||
.ofLocalizedDateTime(FormatStyle.MEDIUM)
|
||||
.withZone(ZoneId.systemDefault())
|
||||
return when {
|
||||
diffSec < 60 -> "just now"
|
||||
diffSec < 3600 -> "${diffSec / 60}m ago"
|
||||
diffSec < 86400 -> "${diffSec / 3600}h ago"
|
||||
diffSec < 7 * 86400 -> "${diffSec / 86400}d ago"
|
||||
else -> absFormatter.format(instant)
|
||||
}
|
||||
}
|
||||
|
||||
private val searchSnippetJson = Json { isLenient = true }
|
||||
|
||||
/**
|
||||
* Makes a backend FTS snippet safe to present as prose. Some indexed messages are stored as
|
||||
* JSON, so prefer their human-readable text fields rather than rendering the entire payload.
|
||||
*/
|
||||
internal fun cleanSearchSnippet(snippet: String): String {
|
||||
val withoutHighlightMarkers = snippet.replace(">>>", "").replace("<<<", "").trim()
|
||||
val parsedSnippet =
|
||||
runCatching { searchSnippetJson.parseToJsonElement(withoutHighlightMarkers) }.getOrNull()
|
||||
val displayText = parsedSnippet?.searchText() ?: withoutHighlightMarkers
|
||||
return displayText
|
||||
.replace(Regex("\\s+"), " ")
|
||||
.trim()
|
||||
}
|
||||
|
||||
private fun JsonElement.searchText(): String? =
|
||||
when (this) {
|
||||
is JsonPrimitive -> {
|
||||
contentOrNull?.takeIf(String::isNotBlank)
|
||||
}
|
||||
|
||||
is JsonArray -> {
|
||||
mapNotNull { it.searchText() }
|
||||
.joinToString(" ")
|
||||
.takeIf(String::isNotBlank)
|
||||
}
|
||||
|
||||
is JsonObject -> {
|
||||
val textFieldNames =
|
||||
listOf("content", "text", "message", "body", "prompt", "summary", "title")
|
||||
val wrapperFieldNames = listOf("data", "result", "payload", "parts")
|
||||
textFieldNames
|
||||
.firstNotNullOfOrNull { fieldName -> this[fieldName]?.searchText() }
|
||||
?: wrapperFieldNames.firstNotNullOfOrNull { fieldName -> this[fieldName]?.searchText() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun displayedSessions(state: SessionsUiState): List<SessionTreeItem> =
|
||||
if (state.isSearchMode) {
|
||||
state.searchResults.map { searchResult ->
|
||||
|
||||
@@ -147,11 +147,17 @@ class SessionsViewModel :
|
||||
rawPaginationOffset = data.nextOffset(0)
|
||||
_uiState.update {
|
||||
val newSessions = data.sessions.orEmpty().pinnedFirst()
|
||||
val hasMore = data.sessions.size >= PAGE_SIZE && data.total > newSessions.size
|
||||
val paging =
|
||||
SessionsPaging.resolveInitialPaging(
|
||||
receivedCount = data.sessions.size,
|
||||
pageSize = PAGE_SIZE,
|
||||
backendTotal = data.total,
|
||||
accumulatedCount = newSessions.size,
|
||||
)
|
||||
it.copy(
|
||||
sessions = newSessions,
|
||||
total = if (hasMore) data.total else newSessions.size,
|
||||
hasMore = hasMore,
|
||||
total = paging.total,
|
||||
hasMore = paging.hasMore,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -229,14 +235,20 @@ class SessionsViewModel :
|
||||
if (requestGeneration != generation) return@safeLaunchLoad
|
||||
rawPaginationOffset = data.nextOffset(0)
|
||||
val sessionsList = data.sessions.orEmpty().pinnedFirst()
|
||||
val hasMore = data.sessions.size >= PAGE_SIZE && data.total > sessionsList.size
|
||||
val paging =
|
||||
SessionsPaging.resolveInitialPaging(
|
||||
receivedCount = data.sessions.size,
|
||||
pageSize = PAGE_SIZE,
|
||||
backendTotal = data.total,
|
||||
accumulatedCount = sessionsList.size,
|
||||
)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
isLoadingMore = false,
|
||||
sessions = sessionsList,
|
||||
total = if (hasMore) data.total else sessionsList.size,
|
||||
hasMore = hasMore,
|
||||
total = paging.total,
|
||||
hasMore = paging.hasMore,
|
||||
selectedIds = emptySet(),
|
||||
)
|
||||
}
|
||||
@@ -284,14 +296,20 @@ class SessionsViewModel :
|
||||
(it.sessions + data.sessions)
|
||||
.distinctBy { s -> s.id }
|
||||
.pinnedFirst()
|
||||
val receivedFullPage = data.sessions.size >= PAGE_SIZE
|
||||
val addedNewItems = newSessions.size > it.sessions.size
|
||||
val hasMore = receivedFullPage && addedNewItems && data.total > newSessions.size
|
||||
val paging =
|
||||
SessionsPaging.resolveLoadMorePaging(
|
||||
receivedCount = data.sessions.size,
|
||||
pageSize = PAGE_SIZE,
|
||||
addedNewItems = addedNewItems,
|
||||
backendTotal = data.total,
|
||||
accumulatedCount = newSessions.size,
|
||||
)
|
||||
it.copy(
|
||||
isLoadingMore = false,
|
||||
sessions = newSessions,
|
||||
total = if (hasMore) data.total else newSessions.size,
|
||||
hasMore = hasMore,
|
||||
total = paging.total,
|
||||
hasMore = paging.hasMore,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user