[新增] 字典的导入功能

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) 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) { suspend fun enableByIds(names: Set<String>) = withContext(Dispatchers.IO) {
if (names.isEmpty()) return@withContext if (names.isEmpty()) return@withContext
val rules = dao.getByNames(names) val rules = dao.getByNames(names)
@@ -1,5 +1,8 @@
package io.legado.app.ui.dict.rule 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.AnimatedVisibility
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut 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.Add
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButton
@@ -34,6 +40,8 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.PlainTooltip import androidx.compose.material3.PlainTooltip
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TooltipAnchorPosition import androidx.compose.material3.TooltipAnchorPosition
@@ -41,6 +49,7 @@ import androidx.compose.material3.TooltipBox
import androidx.compose.material3.TooltipDefaults import androidx.compose.material3.TooltipDefaults
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.animateFloatingActionButton import androidx.compose.material3.animateFloatingActionButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.rememberTooltipState import androidx.compose.material3.rememberTooltipState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -48,19 +57,25 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.nestedscroll.nestedScroll 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.platform.LocalHapticFeedback
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex 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.R
import io.legado.app.data.entities.DictRule 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.ActionItem
import io.legado.app.ui.widget.components.AnimatedText import io.legado.app.ui.widget.components.AnimatedText
import io.legado.app.ui.widget.components.DraggableSelectionHandler 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.ReorderableSelectionItem
import io.legado.app.ui.widget.components.SearchBarSection import io.legado.app.ui.widget.components.SearchBarSection
import io.legado.app.ui.widget.components.SelectionBottomBar 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.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 import sh.calvin.reorderable.rememberReorderableLazyListState
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun DictRuleScreen( fun DictRuleScreen(
viewModel: DictRuleViewModel = viewModel(), viewModel: DictRuleViewModel = koinViewModel(),
onBackClick: () -> Unit onBackClick: () -> Unit
) { ) {
val uploadRepository: UploadRepository = koinInject()
val context = LocalContext.current
val scope = rememberCoroutineScope()
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
val rules = uiState.items
val listState = rememberLazyListState() val listState = rememberLazyListState()
var isSearch by remember { mutableStateOf(false) } var isSearch by remember { mutableStateOf(false) }
var showMenu by remember { mutableStateOf(false) }
val selectedIds by viewModel.selectedIds.collectAsState() val selectedIds by viewModel.selectedIds.collectAsState()
val inSelectionMode = selectedIds.isNotEmpty() val inSelectionMode = selectedIds.isNotEmpty()
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
@@ -89,11 +119,129 @@ fun DictRuleScreen(
var showEditSheet by remember { mutableStateOf(false) } var showEditSheet by remember { mutableStateOf(false) }
var editingRule by remember { mutableStateOf<DictRule?>(null) } var editingRule by remember { mutableStateOf<DictRule?>(null) }
var showDeleteRuleDialog by remember { mutableStateOf<DictRule?>(null) } var showDeleteRuleDialog by remember { mutableStateOf<DictRule?>(null) }
val reorderableState = rememberReorderableLazyListState(listState) { from, to -> val reorderableState = rememberReorderableLazyListState(listState) { from, to ->
viewModel.moveItemInList(from.index, to.index) viewModel.moveItemInList(from.index, to.index)
hapticFeedback.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick) 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) { LaunchedEffect(reorderableState.isAnyItemDragging) {
if (!reorderableState.isAnyItemDragging) { if (!reorderableState.isAnyItemDragging) {
viewModel.saveSortOrder() viewModel.saveSortOrder()
@@ -178,9 +326,10 @@ fun DictRuleScreen(
Column { Column {
GlassMediumFlexibleTopAppBar( GlassMediumFlexibleTopAppBar(
title = { title = {
val titleText = remember(inSelectionMode, selectedIds, uiState.items) { val titleText = remember(isUploading, inSelectionMode, selectedIds, rules) {
when { when {
inSelectionMode -> "已选择 ${selectedIds.size}/${uiState.items.size}" isUploading -> "正在上传..."
inSelectionMode -> "已选择 ${selectedIds.size}/${rules.size}"
else -> "字典规则" else -> "字典规则"
} }
} }
@@ -209,6 +358,32 @@ fun DictRuleScreen(
IconButton(onClick = { isSearch = !isSearch }) { IconButton(onClick = { isSearch = !isSearch }) {
Icon(Icons.Default.Search, contentDescription = "Search") 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 scrollBehavior = scrollBehavior
@@ -260,7 +435,7 @@ fun DictRuleScreen(
), ),
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
items(uiState.items, key = { it.name }) { item -> items(rules, key = { it.name }) { item ->
ReorderableSelectionItem( ReorderableSelectionItem(
state = reorderableState, state = reorderableState,
key = item.name, key = item.name,
@@ -294,7 +469,7 @@ fun DictRuleScreen(
if (inSelectionMode) { if (inSelectionMode) {
DraggableSelectionHandler( DraggableSelectionHandler(
listState = listState, listState = listState,
items = uiState.items, items = rules,
selectedIds = selectedIds, selectedIds = selectedIds,
onSelectionChange = viewModel::setSelection, onSelectionChange = viewModel::setSelection,
idProvider = { it.name }, idProvider = { it.name },
@@ -316,10 +491,10 @@ fun DictRuleScreen(
) { ) {
SelectionBottomBar( SelectionBottomBar(
onSelectAll = { onSelectAll = {
viewModel.setSelection(uiState.items.map { it.name }.toSet()) viewModel.setSelection(rules.map { it.name }.toSet())
}, },
onSelectInvert = { onSelectInvert = {
val allIds = uiState.items.map { it.name }.toSet() val allIds = rules.map { it.name }.toSet()
viewModel.setSelection(allIds - selectedIds) viewModel.setSelection(allIds - selectedIds)
}, },
primaryAction = ActionItem( primaryAction = ActionItem(
@@ -341,6 +516,10 @@ fun DictRuleScreen(
viewModel.disableSelectionByIds(selectedIds) viewModel.disableSelectionByIds(selectedIds)
viewModel.setSelection(emptySet()) 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 android.app.Application
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.core.net.toUri
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import io.legado.app.base.BaseViewModel import io.legado.app.base.BaseViewModel
import io.legado.app.constant.AppConst
import io.legado.app.data.entities.DictRule import io.legado.app.data.entities.DictRule
import io.legado.app.data.repository.DictRuleRepository 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.GSON
import io.legado.app.utils.fromJsonArray
import io.legado.app.utils.fromJsonObject import io.legado.app.utils.fromJsonObject
import io.legado.app.utils.getClipText 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.sendToClip
import io.legado.app.utils.toastOnUi import io.legado.app.utils.toastOnUi
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -26,6 +40,7 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@Immutable @Immutable
data class DictRuleItemUi( data class DictRuleItemUi(
@@ -51,8 +66,9 @@ class DictRuleViewModel(application: Application) : BaseViewModel(application) {
private val _uiRules = MutableStateFlow<List<DictRuleItemUi>>(emptyList()) private val _uiRules = MutableStateFlow<List<DictRuleItemUi>>(emptyList())
private val _selectedIds = MutableStateFlow<Set<String>>(emptySet()) 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) { fun toggleSelection(id: String) {
_selectedIds.update { _selectedIds.update {
if (it.contains(id)) it - id else it + id if (it.contains(id)) it - id else it + id
@@ -172,20 +188,108 @@ class DictRuleViewModel(application: Application) : BaseViewModel(application) {
} }
} }
fun upSortNumber() { fun importSource(text: String) {
execute { _importState.value = BaseImportUiState.Loading
val rules = repository.getAll() viewModelScope.launch(Dispatchers.IO) {
for ((index, rule) in rules.withIndex()) { runCatching {
rule.sortNumber = index + 1 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() { private suspend fun resolveSource(text: String): String {
execute { return when {
DefaultData.importDefaultDictRules() 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) { fun copyRule(dictRule: DictRule) {