Просмотр исходного кода

[新增] 字典的导入功能

HapeLee 9 месяцев назад
Родитель
Сommit
01d0ad2e99

+ 9 - 0
app/src/main/java/io/legado/app/data/repository/DictRuleRepository.kt

@@ -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)

+ 187 - 8
app/src/main/java/io/legado/app/ui/dict/rule/DictRuleScreen.kt

@@ -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 }
                         )
                     )
                 )

+ 116 - 12
app/src/main/java/io/legado/app/ui/dict/rule/DictRuleViewModel.kt

@@ -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))
     }