[新增] 字典的导入功能
This commit is contained in:
@@ -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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user