Merge branch 'claude/admiring-mahavira-a862d1' into artifacts

This commit is contained in:
Kgothatso Ngako
2026-07-26 02:30:46 +02:00
11 changed files with 535 additions and 8 deletions

View File

@@ -14,4 +14,7 @@ interface MantraChunkDao {
@Query("SELECT * FROM MantraChunk WHERE chapterId = :chapterId ORDER BY `index` ASC")
suspend fun getChunksByChapterId(chapterId: String): List<MantraChunk>
@Query("SELECT * FROM MantraChunk WHERE id = :id")
suspend fun getChunkById(id: String): MantraChunk?
}

View File

@@ -14,4 +14,7 @@ interface MantraTranslationChunkDao {
@Query("SELECT * FROM MantraTranslationChunk WHERE translationChapterId = :translationChapterId ORDER BY `index` ASC")
suspend fun getTranslationChunksByTranslationChapterId(translationChapterId: String): List<MantraTranslationChunk>
@Query("DELETE FROM MantraTranslationChunk WHERE id = :id")
suspend fun deleteById(id: String)
}

View File

@@ -14,4 +14,7 @@ interface MarmotInnerEventDao {
@Upsert
suspend fun upsert(marmotInnerEvent: MarmotInnerEvent)
@Query("DELETE FROM MarmotInnerEvent WHERE id = :id")
suspend fun deleteById(id: String)
}

View File

@@ -158,6 +158,63 @@ class DatabaseMantraRepository(
override suspend fun getChunksForChapter(chapterId: String): List<MantraChunk> =
database.mantraChunkDao().getChunksByChapterId(chapterId)
override suspend fun getChunk(id: String): MantraChunk? =
database.mantraChunkDao().getChunkById(id)
override suspend fun saveTranslationChunk(
translationChapterId: String,
chunkId: String,
text: String,
chatRoomId: String,
userPublicKey: HexKey,
): MantraTranslationChunk? {
// The translation chunk mirrors the source chunk's position.
val sourceChunk = database.mantraChunkDao().getChunkById(chunkId) ?: return null
val translationChunkTemplate = TranslationChunkEvent.build(
translationChapterId = translationChapterId,
chunkId = chunkId,
index = sourceChunk.index,
text = text,
)
val translationChunk = MantraTranslationChunk.fromTranslationChunkEventTemplate(
translationChunkEventTemplate = translationChunkTemplate,
chatRoomId = chatRoomId,
userPublicKey = userPublicKey,
) ?: return null
return try {
// Replace any existing translation chunk for this source chunk. Its id
// is derived from the (now changed) content, so it becomes a new row —
// drop the old one (and its rumor) to keep one per source chunk.
database.mantraTranslationChunkDao()
.getTranslationChunksByTranslationChapterId(translationChapterId)
.filter { it.chunkId == chunkId && it.id != translationChunk.id }
.forEach { stale ->
database.mantraTranslationChunkDao().deleteById(stale.id)
database.marmotInnerEventDao().deleteById(stale.id)
}
database.mantraTranslationChunkDao().upsert(translationChunk)
database.marmotInnerEventDao().upsert(
MarmotInnerEvent(
id = translationChunk.id,
publicKey = translationChunk.publicKey,
kind = TranslationChunkEvent.KIND,
createdAt = translationChunk.createdAt,
tags = translationChunkTemplate.tags,
content = translationChunkTemplate.content,
chatRoomId = translationChunk.chatRoomId,
)
)
translationChunk
} catch (error: Throwable) {
logger.e("Failed to save translation for chunk $chunkId", error)
null
}
}
override suspend fun getTranslationsForArtifact(artifactId: String): List<MantraTranslationArtifactVersion> =
database.mantraTranslationArtifactVersionDao().getTranslationsByArtifactId(artifactId)

View File

@@ -26,6 +26,22 @@ interface MantraRepository {
suspend fun getChunksForChapter(chapterId: String): List<MantraChunk>
suspend fun getChunk(id: String): MantraChunk?
/**
* Create or replace the translation of a source chunk within a translation
* chapter. Any existing translation chunk for the same (translationChapterId,
* chunkId) is replaced. Returns the saved chunk, or null if the source chunk
* can't be found.
*/
suspend fun saveTranslationChunk(
translationChapterId: String,
chunkId: String,
text: String,
chatRoomId: String,
userPublicKey: HexKey,
): MantraTranslationChunk?
suspend fun getTranslationsForArtifact(artifactId: String): List<MantraTranslationArtifactVersion>
suspend fun getTranslation(id: String): MantraTranslationArtifactVersion?
@@ -113,6 +129,16 @@ interface MantraRepository {
override suspend fun getChunksForChapter(chapterId: String): List<MantraChunk> = emptyList()
override suspend fun getChunk(id: String): MantraChunk? = null
override suspend fun saveTranslationChunk(
translationChapterId: String,
chunkId: String,
text: String,
chatRoomId: String,
userPublicKey: HexKey,
): MantraTranslationChunk? = null
override suspend fun getTranslationsForArtifact(artifactId: String): List<MantraTranslationArtifactVersion> = emptyList()
override suspend fun getTranslation(id: String): MantraTranslationArtifactVersion? = null

View File

@@ -0,0 +1,223 @@
package press.mantra.compose.ui.composable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Save
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import press.mantra.compose.database.model.MantraChunk
import press.mantra.compose.repository.MantraRepository
import press.mantra.compose.ui.composable.navigation.routes.Route
import press.mantra.compose.ui.composable.navigation.routes.TranslationChapterRoute
import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator
import press.mantra.compose.ui.theme.TorchTheme
import press.mantra.compose.ui.view.model.TranslateChunkViewModel
import press.mantra.compose.ui.view.state.TranslateChunkUIState
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TranslateChunkScreen(
activeUserPublicKey: HexKey,
translationChapterId: String,
chunkId: String,
chatRoomId: String,
relayHint: String?,
initialTranslateChunkUIState: TranslateChunkUIState = TranslateChunkUIState.Loading,
mantraRepository: MantraRepository,
onNavigateToRoute: (Route) -> Unit,
onNavigateBack: () -> Unit,
) {
val translateChunkViewModel: TranslateChunkViewModel = viewModel(
factory = TranslateChunkViewModel.factory(
activeUserPublicKey = activeUserPublicKey,
translationChapterId = translationChapterId,
chunkId = chunkId,
chatRoomId = chatRoomId,
relayHint = relayHint,
initialTranslateChunkUIState = initialTranslateChunkUIState,
mantraRepository = mantraRepository,
)
)
when (val translateChunkUIState = translateChunkViewModel.translateChunkUIState) {
is TranslateChunkUIState.Error -> {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(50.dp))
Text(text = translateChunkUIState.message)
}
}
is TranslateChunkUIState.Loaded -> {
val translationFieldState = rememberTextFieldState(translateChunkUIState.existingTranslationText)
Scaffold(
topBar = {
TopAppBar(
title = { Text("Translate chunk") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back"
)
}
},
)
},
bottomBar = {
BottomAppBar(
actions = {},
floatingActionButton = {
ExtendedFloatingActionButton(
onClick = {
translateChunkViewModel.saveTranslation(
translationField = translationFieldState,
onSuccess = {
// Return to a freshly-loaded chapter table so the
// saved translation is reflected.
onNavigateToRoute.invoke(
TranslationChapterRoute(
activeUserPublicKey = activeUserPublicKey,
translationChapterId = translationChapterId,
chatRoomId = chatRoomId,
relayHint = relayHint
)
)
},
onFailure = {}
)
}
) {
Icon(
Icons.Default.Save,
contentDescription = "Save translation"
)
Text("Save")
}
}
)
}
) { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
text = "Original",
style = MaterialTheme.typography.labelMedium
)
Card {
Text(
modifier = Modifier.padding(16.dp),
text = translateChunkUIState.originalChunk.text,
style = MaterialTheme.typography.bodyMedium
)
}
Text(
text = "Translation",
style = MaterialTheme.typography.labelMedium
)
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
state = translationFieldState,
label = { Text("Translated text") },
placeholder = { Text("Enter the translation for this chunk") },
)
}
}
}
TranslateChunkUIState.Loading -> {
Column(
modifier = Modifier.fillMaxWidth().padding(20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(20.dp)
) {
Spacer(modifier = Modifier.weight(1f))
Text(
text = "Translate Chunk",
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center
)
LoadingDataIndicator(fillScreen = false)
Spacer(modifier = Modifier.weight(2f))
}
}
}
LaunchedEffect(true) {
if (initialTranslateChunkUIState == TranslateChunkUIState.Loading) {
translateChunkViewModel.initiateTranslateChunk()
}
}
}
@Preview
@Composable
private fun TranslateChunkScreenPreview() {
TorchTheme {
Surface(modifier = Modifier.fillMaxSize()) {
TranslateChunkScreen(
activeUserPublicKey = "",
translationChapterId = "translationChapterId",
chunkId = "chunkId",
chatRoomId = "chatRoomId",
relayHint = null,
initialTranslateChunkUIState = TranslateChunkUIState.Loaded(
originalChunk = MantraChunk(
id = "chunkId",
chapterId = "chapterId",
publicKey = "author",
text = "When he was nearly thirteen, my brother Jem got his arm badly broken at the elbow.",
index = 0,
wordCount = 0,
characterCount = 0,
signature = "",
chatRoomId = "chatRoomId"
),
existingTranslationText = ""
),
mantraRepository = MantraRepository.NO_OP_MANTRA_REPOSITORY,
onNavigateToRoute = {},
onNavigateBack = {}
)
}
}
}

View File

@@ -10,11 +10,13 @@ import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
@@ -23,6 +25,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable
@@ -37,6 +40,8 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import press.mantra.compose.database.model.MantraChunk
import press.mantra.compose.repository.MantraRepository
import press.mantra.compose.ui.composable.navigation.routes.Route
import press.mantra.compose.ui.composable.navigation.routes.TranslateChunkRoute
import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator
import press.mantra.compose.ui.theme.TorchTheme
import press.mantra.compose.ui.view.model.TranslationChapterViewModel
@@ -52,6 +57,7 @@ fun TranslationChapterScreen(
relayHint: String?,
initialTranslationChapterUIState: TranslationChapterUIState = TranslationChapterUIState.Loading,
mantraRepository: MantraRepository,
onNavigateToRoute: (Route) -> Unit,
onNavigateBack: () -> Unit,
) {
val translationChapterViewModel: TranslationChapterViewModel = viewModel(
@@ -128,7 +134,20 @@ fun TranslationChapterScreen(
items = translationChapterUIState.rows,
key = { pair -> pair.originalChunk.id }
) { pair ->
ChunkTranslationRow(pair)
ChunkTranslationRow(
pair = pair,
onClick = {
onNavigateToRoute.invoke(
TranslateChunkRoute(
activeUserPublicKey = activeUserPublicKey,
translationChapterId = translationChapterId,
chunkId = pair.originalChunk.id,
chatRoomId = chatRoomId,
relayHint = relayHint
)
)
}
)
HorizontalDivider()
}
}
@@ -162,18 +181,35 @@ fun TranslationChapterScreen(
}
@Composable
private fun ChunkTranslationRow(pair: ChunkTranslationPair) {
private fun ChunkTranslationRow(
pair: ChunkTranslationPair,
onClick: () -> Unit,
) {
val translated = pair.translationChunk?.text?.takeIf { it.isNotBlank() }
TableRow(
left = { Text(pair.originalChunk.text) },
right = {
if (translated != null) {
Text(translated)
} else {
// No translation yet: show the original as a greyed-out placeholder.
// The translation cell is a button that opens the chunk translation
// editor. When there is no translation yet, the original text is
// shown greyed out as a placeholder.
TextButton(
onClick = onClick,
modifier = Modifier.fillMaxWidth(),
contentPadding = PaddingValues(0.dp)
) {
Text(
text = pair.originalChunk.text,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
modifier = Modifier.weight(1f),
text = translated ?: pair.originalChunk.text,
color = if (translated != null) {
MaterialTheme.colorScheme.onSurface
} else {
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
}
)
Icon(
Icons.Default.ChevronRight,
contentDescription = "Translate this chunk",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
@@ -219,6 +255,7 @@ private fun TranslationChapterScreenPreview() {
)
),
mantraRepository = MantraRepository.NO_OP_MANTRA_REPOSITORY,
onNavigateToRoute = {},
onNavigateBack = {}
)
}

View File

@@ -93,12 +93,14 @@ import press.mantra.compose.ui.composable.navigation.routes.AddChapterRoute
import press.mantra.compose.ui.composable.navigation.routes.AddTranslationRoute
import press.mantra.compose.ui.composable.navigation.routes.ArtifactDetailRoute
import press.mantra.compose.ui.composable.navigation.routes.ChapterDetailRoute
import press.mantra.compose.ui.composable.navigation.routes.TranslateChunkRoute
import press.mantra.compose.ui.composable.navigation.routes.TranslationChapterRoute
import press.mantra.compose.ui.composable.navigation.routes.TranslationDetailRoute
import press.mantra.compose.ui.composable.ArtifactDetailScreen
import press.mantra.compose.ui.composable.AddChapterScreen
import press.mantra.compose.ui.composable.AddTranslationScreen
import press.mantra.compose.ui.composable.ChapterDetailScreen
import press.mantra.compose.ui.composable.TranslateChunkScreen
import press.mantra.compose.ui.composable.TranslationChapterScreen
import press.mantra.compose.ui.composable.TranslationDetailScreen
@@ -748,6 +750,35 @@ fun MantraNavHost(
chatRoomId = route.chatRoomId,
relayHint = route.relayHint,
mantraRepository = databaseMantraRepository,
onNavigateToRoute = { actionRoute ->
navController.navigate(
route = actionRoute
)
},
onNavigateBack = {
navController.popBackStack()
}
)
}
composable<TranslateChunkRoute> { backStackEntry ->
val route = backStackEntry.toRoute<TranslateChunkRoute>()
TranslateChunkScreen(
activeUserPublicKey = route.activeUserPublicKey,
translationChapterId = route.translationChapterId,
chunkId = route.chunkId,
chatRoomId = route.chatRoomId,
relayHint = route.relayHint,
mantraRepository = databaseMantraRepository,
onNavigateToRoute = { actionRoute ->
// Replace this editor and the stale chapter table beneath it so
// we land on a freshly-loaded table reflecting the saved translation.
navController.navigate(route = actionRoute) {
popUpTo<TranslationChapterRoute> {
inclusive = true
}
}
},
onNavigateBack = {
navController.popBackStack()
}

View File

@@ -0,0 +1,12 @@
package press.mantra.compose.ui.composable.navigation.routes
import kotlinx.serialization.Serializable
@Serializable
data class TranslateChunkRoute(
val activeUserPublicKey: String,
val translationChapterId: String,
val chunkId: String,
val chatRoomId: String,
val relayHint: String?
): Route()

View File

@@ -0,0 +1,116 @@
package press.mantra.compose.ui.view.model
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import co.touchlab.kermit.Logger
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.launch
import press.mantra.compose.repository.MantraRepository
import press.mantra.compose.ui.view.state.TranslateChunkUIState
class TranslateChunkViewModel(
val translationChapterId: String,
val chunkId: String,
val chatRoomId: String,
val activeUserPublicKey: HexKey,
val relayHint: String?,
initialTranslateChunkUIState: TranslateChunkUIState,
val mantraRepository: MantraRepository,
): ViewModel() {
var translateChunkUIState: TranslateChunkUIState by mutableStateOf(initialTranslateChunkUIState)
private set
private val logger = Logger.withTag(TAG)
val isActionPending: MutableState<Boolean> = mutableStateOf(false)
fun initiateTranslateChunk() {
viewModelScope.launch(Dispatchers.IO) {
val originalChunk = mantraRepository.getChunk(chunkId)
translateChunkUIState = if (originalChunk == null) {
TranslateChunkUIState.Error("Couldn't find the chunk")
} else {
val existing = mantraRepository.getTranslationChunks(translationChapterId)
.firstOrNull { it.chunkId == chunkId }
TranslateChunkUIState.Loaded(
originalChunk = originalChunk,
existingTranslationText = existing?.text.orEmpty(),
)
}
}
}
fun saveTranslation(
translationField: TextFieldState,
onSuccess: () -> Unit,
onFailure: () -> Unit
) {
val text = translationField.text.toString()
if (text.isBlank()) {
onFailure.invoke()
return
}
if (isActionPending.value) return
isActionPending.value = true
viewModelScope.launch(Dispatchers.IO) {
val saved = runCatching {
mantraRepository.saveTranslationChunk(
translationChapterId = translationChapterId,
chunkId = chunkId,
text = text,
chatRoomId = chatRoomId,
userPublicKey = activeUserPublicKey,
)
}.onFailure { error ->
logger.e("Failed to save translation", error)
}.getOrNull()
if (saved != null) {
onSuccess.invoke()
} else {
onFailure.invoke()
}
isActionPending.value = false
}
}
companion object {
private const val TAG = "TranslateChunkViewModel"
fun factory(
activeUserPublicKey: HexKey,
translationChapterId: String,
chunkId: String,
chatRoomId: String,
relayHint: String?,
initialTranslateChunkUIState: TranslateChunkUIState = TranslateChunkUIState.Loading,
mantraRepository: MantraRepository,
): ViewModelProvider.Factory = viewModelFactory {
initializer {
TranslateChunkViewModel(
activeUserPublicKey = activeUserPublicKey,
translationChapterId = translationChapterId,
chunkId = chunkId,
chatRoomId = chatRoomId,
relayHint = relayHint,
initialTranslateChunkUIState = initialTranslateChunkUIState,
mantraRepository = mantraRepository,
)
}
}
}
}

View File

@@ -0,0 +1,16 @@
package press.mantra.compose.ui.view.state
import press.mantra.compose.database.model.MantraChunk
sealed interface TranslateChunkUIState {
data class Loaded(
val originalChunk: MantraChunk,
val existingTranslationText: String = "",
): TranslateChunkUIState
data class Error(
val message: String
): TranslateChunkUIState
data object Loading: TranslateChunkUIState
}