[新增] 字典的导入功能

This commit is contained in:
HapeLee
2026-01-25 06:13:11 +08:00
parent cfa63797c9
commit 01d0ad2e99
3 changed files with 312 additions and 20 deletions
@@ -34,6 +34,15 @@ class DictRuleRepository {
dao.update(*rule)
}
suspend fun findById(id: String): DictRule? = withContext(Dispatchers.IO) {
dao.getByName(id)
}
suspend fun getByNames(names: Collection<String>): List<DictRule> =
withContext(Dispatchers.IO) {
if (names.isEmpty()) emptyList() else dao.getByNames(names.toSet())
}
suspend fun enableByIds(names: Set<String>) = withContext(Dispatchers.IO) {
if (names.isEmpty()) return@withContext
val rules = dao.getByNames(names)
@@ -1,5 +1,8 @@
package io.legado.app.ui.dict.rule
import android.content.ClipData
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
@@ -21,9 +24,12 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FloatingActionButton
@@ -34,6 +40,8 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.PlainTooltip
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TooltipAnchorPosition
@@ -41,6 +49,7 @@ import androidx.compose.material3.TooltipBox
import androidx.compose.material3.TooltipDefaults
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.animateFloatingActionButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.rememberTooltipState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -48,19 +57,25 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.gson.Gson
import io.legado.app.R
import io.legado.app.data.entities.DictRule
import io.legado.app.data.repository.UploadRepository
import io.legado.app.ui.widget.components.ActionItem
import io.legado.app.ui.widget.components.AnimatedText
import io.legado.app.ui.widget.components.DraggableSelectionHandler
@@ -68,19 +83,34 @@ import io.legado.app.ui.widget.components.GlassMediumFlexibleTopAppBar
import io.legado.app.ui.widget.components.ReorderableSelectionItem
import io.legado.app.ui.widget.components.SearchBarSection
import io.legado.app.ui.widget.components.SelectionBottomBar
import io.legado.app.ui.widget.components.exportComponents.FilePickerSheet
import io.legado.app.ui.widget.components.exportComponents.FilePickerSheetMode
import io.legado.app.ui.widget.components.importComponents.BaseImportUiState
import io.legado.app.ui.widget.components.importComponents.BatchImportDialog
import io.legado.app.ui.widget.components.importComponents.SourceInputDialog
import io.legado.app.ui.widget.components.lazylist.FastScrollLazyColumn
import io.legado.app.utils.GSON
import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel
import org.koin.compose.koinInject
import sh.calvin.reorderable.rememberReorderableLazyListState
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun DictRuleScreen(
viewModel: DictRuleViewModel = viewModel(),
viewModel: DictRuleViewModel = koinViewModel(),
onBackClick: () -> Unit
) {
val uploadRepository: UploadRepository = koinInject()
val context = LocalContext.current
val scope = rememberCoroutineScope()
val uiState by viewModel.uiState.collectAsState()
val rules = uiState.items
val listState = rememberLazyListState()
var isSearch by remember { mutableStateOf(false) }
var showMenu by remember { mutableStateOf(false) }
val selectedIds by viewModel.selectedIds.collectAsState()
val inSelectionMode = selectedIds.isNotEmpty()
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
@@ -89,11 +119,129 @@ fun DictRuleScreen(
var showEditSheet by remember { mutableStateOf(false) }
var editingRule by remember { mutableStateOf<DictRule?>(null) }
var showDeleteRuleDialog by remember { mutableStateOf<DictRule?>(null) }
val reorderableState = rememberReorderableLazyListState(listState) { from, to ->
viewModel.moveItemInList(from.index, to.index)
hapticFeedback.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick)
}
val clipboardManager = LocalClipboard.current
val snackbarHostState = remember { SnackbarHostState() }
var showUrlInput by remember { mutableStateOf(false) }
var showFilePickerSheet by remember { mutableStateOf(false) }
var filePickerMode by remember { mutableStateOf(FilePickerSheetMode.EXPORT) }
var isUploading by remember { mutableStateOf(false) }
val importState by viewModel.importState.collectAsStateWithLifecycle()
val sheetState = rememberModalBottomSheetState()
val importDoc = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument(),
onResult = { uri ->
uri?.let {
context.contentResolver.openInputStream(it)?.use { stream ->
val text = stream.reader().readText()
viewModel.importSource(text)
}
}
}
)
val exportDoc = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("application/json"),
onResult = { uri ->
uri?.let { it ->
scope.launch {
val rulesToExport = rules
.filter { selectedIds.contains(it.name) }
.map { it.rule }
val json = Gson().toJson(rulesToExport)
context.contentResolver.openOutputStream(it)?.use { stream ->
stream.writer().write(json)
}
}
}
}
)
if (showUrlInput) {
SourceInputDialog(
title = stringResource(R.string.import_on_line),
onDismissRequest = { showUrlInput = false },
onConfirm = {
showUrlInput = false
viewModel.importSource(it)
}
)
}
if (showFilePickerSheet) {
FilePickerSheet(
sheetState = sheetState,
onDismissRequest = { showFilePickerSheet = false },
mode = filePickerMode,
onSelectSysDir = {
showFilePickerSheet = false
exportDoc.launch("exportDictRule.json")
},
onSelectSysFile = {},
onUpload = {
showFilePickerSheet = false
scope.launch {
val selectedRules = viewModel.getSelectedRules()
val json = GSON.toJson(selectedRules)
isUploading = true
try {
runCatching {
uploadRepository.upload(
fileName = "exportDictRule.json",
file = json,
contentType = "application/json"
)
}.onSuccess { url ->
isUploading = false
val result = snackbarHostState.showSnackbar(
message = "上传成功: $url",
actionLabel = "复制链接",
withDismissAction = true
)
if (result == SnackbarResult.ActionPerformed) {
clipboardManager.setClipEntry(
ClipEntry(ClipData.newPlainText("export url", url))
)
}
}.onFailure { e ->
isUploading = false
snackbarHostState.showSnackbar("上传失败: ${e.localizedMessage}")
}
} finally {
isUploading = false
}
}
},
allowExtensions = arrayOf("json")
)
}
(importState as? BaseImportUiState.Success<DictRule>)?.let { state ->
BatchImportDialog(
title = "导入词典规则",
importState = state,
onDismissRequest = { viewModel.cancelImport() },
onToggleItem = { viewModel.toggleImportSelection(it) },
onToggleAll = { viewModel.toggleImportAll(it) },
onConfirm = { viewModel.saveImportedRules() },
itemContent = { rule, _ ->
Column {
Text(rule.name, style = MaterialTheme.typography.titleMedium)
Text(rule.urlRule, style = MaterialTheme.typography.bodySmall, maxLines = 1)
}
}
)
}
LaunchedEffect(reorderableState.isAnyItemDragging) {
if (!reorderableState.isAnyItemDragging) {
viewModel.saveSortOrder()
@@ -178,9 +326,10 @@ fun DictRuleScreen(
Column {
GlassMediumFlexibleTopAppBar(
title = {
val titleText = remember(inSelectionMode, selectedIds, uiState.items) {
val titleText = remember(isUploading, inSelectionMode, selectedIds, rules) {
when {
inSelectionMode -> "已选择 ${selectedIds.size}/${uiState.items.size}"
isUploading -> "正在上传..."
inSelectionMode -> "已选择 ${selectedIds.size}/${rules.size}"
else -> "字典规则"
}
}
@@ -209,6 +358,32 @@ fun DictRuleScreen(
IconButton(onClick = { isSearch = !isSearch }) {
Icon(Icons.Default.Search, contentDescription = "Search")
}
IconButton(onClick = { showMenu = !showMenu }) {
Icon(Icons.Default.MoreVert, contentDescription = "More")
}
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false }
) {
DropdownMenuItem(
text = { Text("在线导入") },
onClick = {
showMenu = false
showUrlInput = true
}
)
DropdownMenuItem(
text = { Text("本地导入") },
onClick = {
importDoc.launch(
arrayOf(
"text/plain",
"application/json"
)
); showMenu = false
}
)
}
}
},
scrollBehavior = scrollBehavior
@@ -260,7 +435,7 @@ fun DictRuleScreen(
),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(uiState.items, key = { it.name }) { item ->
items(rules, key = { it.name }) { item ->
ReorderableSelectionItem(
state = reorderableState,
key = item.name,
@@ -294,7 +469,7 @@ fun DictRuleScreen(
if (inSelectionMode) {
DraggableSelectionHandler(
listState = listState,
items = uiState.items,
items = rules,
selectedIds = selectedIds,
onSelectionChange = viewModel::setSelection,
idProvider = { it.name },
@@ -316,10 +491,10 @@ fun DictRuleScreen(
) {
SelectionBottomBar(
onSelectAll = {
viewModel.setSelection(uiState.items.map { it.name }.toSet())
viewModel.setSelection(rules.map { it.name }.toSet())
},
onSelectInvert = {
val allIds = uiState.items.map { it.name }.toSet()
val allIds = rules.map { it.name }.toSet()
viewModel.setSelection(allIds - selectedIds)
},
primaryAction = ActionItem(
@@ -341,6 +516,10 @@ fun DictRuleScreen(
viewModel.disableSelectionByIds(selectedIds)
viewModel.setSelection(emptySet())
}
),
ActionItem(
text = stringResource(R.string.export),
onClick = { showFilePickerSheet = true }
)
)
)
@@ -2,14 +2,28 @@ package io.legado.app.ui.dict.rule
import android.app.Application
import androidx.compose.runtime.Immutable
import androidx.core.net.toUri
import androidx.lifecycle.viewModelScope
import io.legado.app.base.BaseViewModel
import io.legado.app.constant.AppConst
import io.legado.app.data.entities.DictRule
import io.legado.app.data.repository.DictRuleRepository
import io.legado.app.help.DefaultData
import io.legado.app.help.http.decompressed
import io.legado.app.help.http.newCallResponseBody
import io.legado.app.help.http.okHttpClient
import io.legado.app.help.http.text
import io.legado.app.ui.widget.components.importComponents.BaseImportUiState
import io.legado.app.ui.widget.components.importComponents.ImportItemWrapper
import io.legado.app.ui.widget.components.importComponents.ImportStatus
import io.legado.app.utils.GSON
import io.legado.app.utils.fromJsonArray
import io.legado.app.utils.fromJsonObject
import io.legado.app.utils.getClipText
import io.legado.app.utils.isAbsUrl
import io.legado.app.utils.isJsonArray
import io.legado.app.utils.isJsonObject
import io.legado.app.utils.isUri
import io.legado.app.utils.readText
import io.legado.app.utils.sendToClip
import io.legado.app.utils.toastOnUi
import kotlinx.coroutines.Dispatchers
@@ -26,6 +40,7 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@Immutable
data class DictRuleItemUi(
@@ -51,8 +66,9 @@ class DictRuleViewModel(application: Application) : BaseViewModel(application) {
private val _uiRules = MutableStateFlow<List<DictRuleItemUi>>(emptyList())
private val _selectedIds = MutableStateFlow<Set<String>>(emptySet())
val selectedIds: StateFlow<Set<String>> = _selectedIds.asStateFlow()
val selectedIds: StateFlow<Set<String>> = _selectedIds
private val _importState = MutableStateFlow<BaseImportUiState<DictRule>>(BaseImportUiState.Idle)
val importState = _importState.asStateFlow()
fun toggleSelection(id: String) {
_selectedIds.update {
if (it.contains(id)) it - id else it + id
@@ -172,22 +188,110 @@ class DictRuleViewModel(application: Application) : BaseViewModel(application) {
}
}
fun upSortNumber() {
execute {
val rules = repository.getAll()
for ((index, rule) in rules.withIndex()) {
rule.sortNumber = index + 1
fun importSource(text: String) {
_importState.value = BaseImportUiState.Loading
viewModelScope.launch(Dispatchers.IO) {
runCatching {
val jsonText = resolveSource(text.trim())
val rules = parseDictRules(jsonText)
val wrappers = rules.map { newRule ->
val oldRule = repository.findById(newRule.name)
val status = when {
oldRule == null -> ImportStatus.New
hasChanged(newRule, oldRule) -> ImportStatus.Update
else -> ImportStatus.Existing
}
ImportItemWrapper(
data = newRule,
oldData = oldRule,
status = status,
isSelected = status != ImportStatus.Existing
)
}
_importState.value = BaseImportUiState.Success(
source = text,
items = wrappers
)
}.onFailure {
it.printStackTrace()
_importState.value = BaseImportUiState.Error(it.localizedMessage ?: "Unknown Error")
}
repository.insert(*rules.toTypedArray())
}
}
fun importDefault() {
execute {
DefaultData.importDefaultDictRules()
private suspend fun resolveSource(text: String): String {
return when {
text.isAbsUrl() -> {
okHttpClient.newCallResponseBody {
if (text.endsWith("#requestWithoutUA")) {
url(text.substringBeforeLast("#requestWithoutUA"))
header(AppConst.UA_NAME, "null")
} else {
url(text)
}
}.decompressed().text("utf-8")
}
text.isUri() -> text.toUri().readText(context)
else -> text
}
}
private fun parseDictRules(text: String): List<DictRule> {
return when {
text.isJsonArray() -> GSON.fromJsonArray<DictRule>(text).getOrThrow()
text.isJsonObject() -> listOf(GSON.fromJsonObject<DictRule>(text).getOrThrow())
else -> throw Exception("格式不正确")
}
}
private fun hasChanged(newRule: DictRule, oldRule: DictRule): Boolean {
return newRule.name != oldRule.name
|| newRule.urlRule != oldRule.urlRule
|| newRule.showRule != oldRule.showRule
}
fun toggleImportSelection(index: Int) {
val currentState = _importState.value as? BaseImportUiState.Success<DictRule> ?: return
val newItems = currentState.items.toMutableList()
val item = newItems[index]
newItems[index] = item.copy(isSelected = !item.isSelected)
_importState.value = currentState.copy(items = newItems)
}
fun toggleImportAll(isSelected: Boolean) {
val currentState = _importState.value as? BaseImportUiState.Success<DictRule> ?: return
val newItems = currentState.items.map { it.copy(isSelected = isSelected) }
_importState.value = currentState.copy(items = newItems)
}
fun saveImportedRules() {
val state = _importState.value as? BaseImportUiState.Success<DictRule> ?: return
viewModelScope.launch(Dispatchers.IO) {
val rulesToSave = state.items
.filter { it.isSelected }
.map { it.data }
repository.insert(*rulesToSave.toTypedArray())
withContext(Dispatchers.Main) {
_importState.value = BaseImportUiState.Idle
}
}
}
fun cancelImport() {
_importState.value = BaseImportUiState.Idle
}
suspend fun getSelectedRules(): List<DictRule> {
val selectedIds = _selectedIds.value
return repository.getByNames(selectedIds)
}
fun copyRule(dictRule: DictRule) {
context.sendToClip(GSON.toJson(dictRule))
}